@clawling/clawchat-plugin-openclaw 2026.8.2-1 → 2026.8.4-1
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/dist/src/liveware-sample.js +80 -10
- package/package.json +1 -1
- package/src/liveware-sample.ts +81 -4
|
@@ -15,6 +15,35 @@ export const LIVEWARE_SAMPLE_APP_NAME = "Liveware Sample";
|
|
|
15
15
|
export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
|
|
16
16
|
/** Files the agent owns at runtime; preserved across sample upgrades. */
|
|
17
17
|
const USER_DATA_FILES = ["state.json", "events.jsonl"];
|
|
18
|
+
/**
|
|
19
|
+
* Windows refuses to unlink or rename a directory while ANY file inside it is
|
|
20
|
+
* still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
|
|
21
|
+
* non-empty directory. A still-draining sample server, an antivirus scan or the
|
|
22
|
+
* search indexer is enough to trigger it, and all of those clear within
|
|
23
|
+
* milliseconds — so a short bounded retry turns a hard install failure into a
|
|
24
|
+
* brief pause. On POSIX these codes essentially never appear here and the first
|
|
25
|
+
* attempt always wins, so the retry costs nothing.
|
|
26
|
+
*/
|
|
27
|
+
const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
|
|
28
|
+
const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
|
|
29
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
30
|
+
async function withFsSwapRetry(op) {
|
|
31
|
+
let lastError;
|
|
32
|
+
for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
33
|
+
try {
|
|
34
|
+
return await op();
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
lastError = err;
|
|
38
|
+
const code = err.code ?? "";
|
|
39
|
+
const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
|
|
40
|
+
if (!TRANSIENT_FS_CODES.has(code) || delay === undefined)
|
|
41
|
+
break;
|
|
42
|
+
await sleep(delay);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw lastError;
|
|
46
|
+
}
|
|
18
47
|
function livewaresBase(base, ref) {
|
|
19
48
|
return `${(base ?? OFFICIAL_SKILLS_BASE).replace(/\/+$/, "")}/${ref}/livewares`;
|
|
20
49
|
}
|
|
@@ -116,6 +145,14 @@ export async function downloadLivewareSample(opts) {
|
|
|
116
145
|
if (await localInstallMatches(appDir, entry)) {
|
|
117
146
|
return { version: entry.version, appDir, reused: true };
|
|
118
147
|
}
|
|
148
|
+
// Sweep app.old-* left behind when a previous swap could not delete the old
|
|
149
|
+
// tree (a handle was still open then, but is not now). Best effort: a dir we
|
|
150
|
+
// still cannot remove is simply retried next run.
|
|
151
|
+
for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
|
|
152
|
+
if (name.startsWith("app.old-")) {
|
|
153
|
+
await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => { });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
119
156
|
const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
|
|
120
157
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
121
158
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
@@ -138,8 +175,18 @@ export async function downloadLivewareSample(opts) {
|
|
|
138
175
|
await fs.promises.copyFile(prev, path.join(tmpDir, name));
|
|
139
176
|
}
|
|
140
177
|
}
|
|
141
|
-
|
|
142
|
-
|
|
178
|
+
// Swap by moving the old tree ASIDE rather than deleting it first. On
|
|
179
|
+
// Windows the delete is the step most likely to lose a race with an open
|
|
180
|
+
// handle, and doing it first leaves no app/ at all when it fails. Renaming
|
|
181
|
+
// aside puts the new tree in place first and makes the delete best-effort:
|
|
182
|
+
// a leftover app.old-<pid> is swept on the next run, never a broken install.
|
|
183
|
+
const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
|
|
184
|
+
await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
|
|
185
|
+
if (fs.existsSync(appDir)) {
|
|
186
|
+
await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
|
|
187
|
+
}
|
|
188
|
+
await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
|
|
189
|
+
await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => { });
|
|
143
190
|
return { version: entry.version, appDir };
|
|
144
191
|
}
|
|
145
192
|
catch (err) {
|
|
@@ -147,6 +194,35 @@ export async function downloadLivewareSample(opts) {
|
|
|
147
194
|
throw err;
|
|
148
195
|
}
|
|
149
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Terminate a child and everything it spawned.
|
|
199
|
+
*
|
|
200
|
+
* POSIX: `SIGTERM` to the child is what we want and what the child expects.
|
|
201
|
+
*
|
|
202
|
+
* Windows: there are no signals. `child.kill()` maps to TerminateProcess on
|
|
203
|
+
* that one process, so any grandchild the liveware agent spawned survives —
|
|
204
|
+
* orphaned, still holding the sample port, and invisible to the next boot,
|
|
205
|
+
* which then fails to bind. `taskkill /T /F` takes the whole tree down.
|
|
206
|
+
* Fire-and-forget: we never wait on it, and a failure (already dead, no such
|
|
207
|
+
* pid) falls through to the ordinary kill().
|
|
208
|
+
*/
|
|
209
|
+
export function killProcessTree(child, execFileFn = nodeExecFile) {
|
|
210
|
+
if (!child)
|
|
211
|
+
return;
|
|
212
|
+
if (process.platform === "win32" && typeof child.pid === "number") {
|
|
213
|
+
try {
|
|
214
|
+
execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => { });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// taskkill missing or spawn refused — fall back to the plain kill below.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
child.kill("SIGTERM");
|
|
223
|
+
}
|
|
224
|
+
catch { /* already dead */ }
|
|
225
|
+
}
|
|
150
226
|
const SERVER_START_TIMEOUT_MS = 10_000;
|
|
151
227
|
const TUNNEL_START_TIMEOUT_MS = 30_000;
|
|
152
228
|
const CLI_TIMEOUT_MS = 30_000;
|
|
@@ -164,10 +240,7 @@ function waitForOutput(child, match, timeoutMs, label) {
|
|
|
164
240
|
child.stderr?.removeListener("data", onData);
|
|
165
241
|
child.removeListener("exit", onExit);
|
|
166
242
|
if (err) {
|
|
167
|
-
|
|
168
|
-
child.kill("SIGTERM");
|
|
169
|
-
}
|
|
170
|
-
catch { /* already dead */ }
|
|
243
|
+
killProcessTree(child);
|
|
171
244
|
reject(err);
|
|
172
245
|
}
|
|
173
246
|
else {
|
|
@@ -535,10 +608,7 @@ export class LivewareSampleSupervisor {
|
|
|
535
608
|
killChildren() {
|
|
536
609
|
this.generation += 1; // invalidate pending exit handlers before killing
|
|
537
610
|
for (const child of [this.serverChild, this.tunnelChild]) {
|
|
538
|
-
|
|
539
|
-
child?.kill("SIGTERM");
|
|
540
|
-
}
|
|
541
|
-
catch { /* already dead */ }
|
|
611
|
+
killProcessTree(child, this.deps.execFileFn);
|
|
542
612
|
}
|
|
543
613
|
this.serverChild = null;
|
|
544
614
|
this.tunnelChild = null;
|
package/package.json
CHANGED
package/src/liveware-sample.ts
CHANGED
|
@@ -20,6 +20,36 @@ export const MAX_SAMPLE_FILE_BYTES = 512 * 1024;
|
|
|
20
20
|
/** Files the agent owns at runtime; preserved across sample upgrades. */
|
|
21
21
|
const USER_DATA_FILES = ["state.json", "events.jsonl"];
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Windows refuses to unlink or rename a directory while ANY file inside it is
|
|
25
|
+
* still open, and reports it as a sharing violation (EPERM/EBUSY/EACCES) or a
|
|
26
|
+
* non-empty directory. A still-draining sample server, an antivirus scan or the
|
|
27
|
+
* search indexer is enough to trigger it, and all of those clear within
|
|
28
|
+
* milliseconds — so a short bounded retry turns a hard install failure into a
|
|
29
|
+
* brief pause. On POSIX these codes essentially never appear here and the first
|
|
30
|
+
* attempt always wins, so the retry costs nothing.
|
|
31
|
+
*/
|
|
32
|
+
const FS_SWAP_RETRY_DELAYS_MS = [50, 150, 400];
|
|
33
|
+
const TRANSIENT_FS_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
|
|
34
|
+
|
|
35
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
|
|
37
|
+
async function withFsSwapRetry<T>(op: () => Promise<T>): Promise<T> {
|
|
38
|
+
let lastError: unknown;
|
|
39
|
+
for (let attempt = 0; attempt <= FS_SWAP_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
40
|
+
try {
|
|
41
|
+
return await op();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
lastError = err;
|
|
44
|
+
const code = (err as NodeJS.ErrnoException).code ?? "";
|
|
45
|
+
const delay = FS_SWAP_RETRY_DELAYS_MS[attempt];
|
|
46
|
+
if (!TRANSIENT_FS_CODES.has(code) || delay === undefined) break;
|
|
47
|
+
await sleep(delay);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
throw lastError;
|
|
51
|
+
}
|
|
52
|
+
|
|
23
53
|
export interface LivewareSampleManifestFile {
|
|
24
54
|
path: string;
|
|
25
55
|
sha256: string;
|
|
@@ -151,6 +181,15 @@ export async function downloadLivewareSample(opts: {
|
|
|
151
181
|
if (await localInstallMatches(appDir, entry)) {
|
|
152
182
|
return { version: entry.version, appDir, reused: true };
|
|
153
183
|
}
|
|
184
|
+
// Sweep app.old-* left behind when a previous swap could not delete the old
|
|
185
|
+
// tree (a handle was still open then, but is not now). Best effort: a dir we
|
|
186
|
+
// still cannot remove is simply retried next run.
|
|
187
|
+
for (const name of await fs.promises.readdir(opts.sampleRoot).catch(() => [])) {
|
|
188
|
+
if (name.startsWith("app.old-")) {
|
|
189
|
+
await fs.promises.rm(path.join(opts.sampleRoot, name), { recursive: true, force: true }).catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
154
193
|
const tmpDir = path.join(opts.sampleRoot, `app.tmp-${process.pid}`);
|
|
155
194
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
156
195
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
@@ -173,8 +212,18 @@ export async function downloadLivewareSample(opts: {
|
|
|
173
212
|
await fs.promises.copyFile(prev, path.join(tmpDir, name));
|
|
174
213
|
}
|
|
175
214
|
}
|
|
176
|
-
|
|
177
|
-
|
|
215
|
+
// Swap by moving the old tree ASIDE rather than deleting it first. On
|
|
216
|
+
// Windows the delete is the step most likely to lose a race with an open
|
|
217
|
+
// handle, and doing it first leaves no app/ at all when it fails. Renaming
|
|
218
|
+
// aside puts the new tree in place first and makes the delete best-effort:
|
|
219
|
+
// a leftover app.old-<pid> is swept on the next run, never a broken install.
|
|
220
|
+
const oldDir = path.join(opts.sampleRoot, `app.old-${process.pid}`);
|
|
221
|
+
await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
|
|
222
|
+
if (fs.existsSync(appDir)) {
|
|
223
|
+
await withFsSwapRetry(() => fs.promises.rename(appDir, oldDir));
|
|
224
|
+
}
|
|
225
|
+
await withFsSwapRetry(() => fs.promises.rename(tmpDir, appDir));
|
|
226
|
+
await fs.promises.rm(oldDir, { recursive: true, force: true }).catch(() => {});
|
|
178
227
|
return { version: entry.version, appDir };
|
|
179
228
|
} catch (err) {
|
|
180
229
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
@@ -185,6 +234,34 @@ export async function downloadLivewareSample(opts: {
|
|
|
185
234
|
export type SpawnLike = typeof nodeSpawn;
|
|
186
235
|
export type ExecFileLike = typeof nodeExecFile;
|
|
187
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Terminate a child and everything it spawned.
|
|
239
|
+
*
|
|
240
|
+
* POSIX: `SIGTERM` to the child is what we want and what the child expects.
|
|
241
|
+
*
|
|
242
|
+
* Windows: there are no signals. `child.kill()` maps to TerminateProcess on
|
|
243
|
+
* that one process, so any grandchild the liveware agent spawned survives —
|
|
244
|
+
* orphaned, still holding the sample port, and invisible to the next boot,
|
|
245
|
+
* which then fails to bind. `taskkill /T /F` takes the whole tree down.
|
|
246
|
+
* Fire-and-forget: we never wait on it, and a failure (already dead, no such
|
|
247
|
+
* pid) falls through to the ordinary kill().
|
|
248
|
+
*/
|
|
249
|
+
export function killProcessTree(
|
|
250
|
+
child: ChildProcess | null | undefined,
|
|
251
|
+
execFileFn: ExecFileLike = nodeExecFile,
|
|
252
|
+
): void {
|
|
253
|
+
if (!child) return;
|
|
254
|
+
if (process.platform === "win32" && typeof child.pid === "number") {
|
|
255
|
+
try {
|
|
256
|
+
execFileFn("taskkill", ["/pid", String(child.pid), "/T", "/F"], () => {});
|
|
257
|
+
return;
|
|
258
|
+
} catch {
|
|
259
|
+
// taskkill missing or spawn refused — fall back to the plain kill below.
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
try { child.kill("SIGTERM"); } catch { /* already dead */ }
|
|
263
|
+
}
|
|
264
|
+
|
|
188
265
|
const SERVER_START_TIMEOUT_MS = 10_000;
|
|
189
266
|
const TUNNEL_START_TIMEOUT_MS = 30_000;
|
|
190
267
|
const CLI_TIMEOUT_MS = 30_000;
|
|
@@ -207,7 +284,7 @@ function waitForOutput(
|
|
|
207
284
|
child.stderr?.removeListener("data", onData);
|
|
208
285
|
child.removeListener("exit", onExit);
|
|
209
286
|
if (err) {
|
|
210
|
-
|
|
287
|
+
killProcessTree(child);
|
|
211
288
|
reject(err);
|
|
212
289
|
} else {
|
|
213
290
|
resolve(value as string);
|
|
@@ -670,7 +747,7 @@ export class LivewareSampleSupervisor {
|
|
|
670
747
|
private killChildren(): void {
|
|
671
748
|
this.generation += 1; // invalidate pending exit handlers before killing
|
|
672
749
|
for (const child of [this.serverChild, this.tunnelChild]) {
|
|
673
|
-
|
|
750
|
+
killProcessTree(child, this.deps.execFileFn);
|
|
674
751
|
}
|
|
675
752
|
this.serverChild = null;
|
|
676
753
|
this.tunnelChild = null;
|