taskforce-loop-engineering 0.8.0 → 0.8.2
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 +10 -0
- package/MIGRATING.md +1 -0
- package/README.md +11 -4
- package/lib/core.mjs +65 -8
- package/package.json +1 -1
- package/scripts/final-judgement-self-test.mjs +30 -1
- package/scripts/openclaw-install-self-test.mjs +36 -6
- package/scripts/openclaw-install.mjs +48 -9
- package/scripts/openclaw-manage.mjs +24 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.2 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
- Classify transcript-compaction timeouts and selected transport failures as recoverable runtime interruptions instead of development revisions. Preserve accepted checkpoints, rotate the worker session, and return the same project task to the queue with bounded recovery attempts.
|
|
6
|
+
- Bound long-lived project sessions by rotating the worker session after a configurable number of successful project ticks (`retry.sessionMaxTicks`, default 10).
|
|
7
|
+
|
|
8
|
+
## 0.8.1 - 2026-08-07
|
|
9
|
+
|
|
10
|
+
- Make the standard OpenClaw installer and upgrade path install and enable a managed per-queue systemd user scheduler, configure required scheduler heartbeats by default, and use an absolute packaged CLI path so systemd does not depend on an interactive shell `PATH`.
|
|
11
|
+
- Make uninstall disable and remove the managed scheduler units, while retaining queue runtime and refusing to overwrite locally modified managed units.
|
|
12
|
+
|
|
3
13
|
## 0.8.0 - 2026-08-07
|
|
4
14
|
|
|
5
15
|
- Add project-aware completion semantics: accepted milestones return project tasks to `inbox/` as `project_in_progress` until an explicit project terminal contract is accepted.
|
package/MIGRATING.md
CHANGED
|
@@ -49,6 +49,7 @@ Use `--confirm-install` only after resolving path conflicts. Existing queue runt
|
|
|
49
49
|
- Worker agent names are installation settings and are not fixed to Ironman.
|
|
50
50
|
- Every task uses an isolated `agent:<worker>:loop-task-<task-id>` session.
|
|
51
51
|
- Asynchronous delivery requires recorded source `channel` and `target`; missing routing metadata fails closed.
|
|
52
|
+
- A confirmed install or managed upgrade now creates and enables a per-queue systemd user scheduler. Queue configs require its heartbeat, so queued project work cannot silently wait forever. Uninstall disables and removes the managed units while retaining queue runtime.
|
|
52
53
|
- `repair-plan` is read-only. Version 0.6 does not add an automatic configuration repair command.
|
|
53
54
|
|
|
54
55
|
After installation or upgrade, validate the integration:
|
package/README.md
CHANGED
|
@@ -59,6 +59,11 @@ already loop-managed to prevent recursive re-enqueue. Existing generated files
|
|
|
59
59
|
are not overwritten unless `--force` is supplied after review. The installed
|
|
60
60
|
conversation policy treats `走 loop` as enqueue plus immediate execution;
|
|
61
61
|
`只入队` and `只排队` remain explicit queue-only overrides.
|
|
62
|
+
The confirmed installer also creates and enables a managed per-queue systemd
|
|
63
|
+
user timer. It wakes the adaptive scheduler once per minute; the persisted
|
|
64
|
+
scheduler cadence still decides whether work is due. Generated queue configs
|
|
65
|
+
require a fresh scheduler heartbeat, so queued work fails `doctor` with
|
|
66
|
+
`scheduler_missing` instead of waiting indefinitely when the timer is absent.
|
|
62
67
|
After every installed runner tick, the wrapper idempotently scans human-input
|
|
63
68
|
gates and terminal tasks. The generated notifier delivers through
|
|
64
69
|
`openclaw message send` using the task's recorded `channel`, `target`, `account`,
|
|
@@ -102,10 +107,12 @@ loop-engineering-openclaw-manage --root /path/to/workspace --action uninstall-pl
|
|
|
102
107
|
loop-engineering-openclaw-manage --root /path/to/workspace --action uninstall --confirm-uninstall
|
|
103
108
|
```
|
|
104
109
|
|
|
105
|
-
The installer manifest records SHA-256 hashes for generated files
|
|
106
|
-
managed `AGENTS.md` block. Upgrade/uninstall refuses when
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
The installer manifest records SHA-256 hashes for generated files, systemd
|
|
111
|
+
units, and the exact managed `AGENTS.md` block. Upgrade/uninstall refuses when
|
|
112
|
+
managed content was edited. Upgrade installs and enables the scheduler for
|
|
113
|
+
older managed integrations. Uninstall first disables the timer, then removes
|
|
114
|
+
only clean managed files, units, and that exact instructions block; queue
|
|
115
|
+
runtime is explicitly retained.
|
|
109
116
|
|
|
110
117
|
## Commands
|
|
111
118
|
|
package/lib/core.mjs
CHANGED
|
@@ -2912,10 +2912,19 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
2912
2912
|
const reviseCount = effectiveReviews.filter((item) => item.status === 'revise').length;
|
|
2913
2913
|
const blockedCount = effectiveReviews.filter((item) => item.status === 'blocked').length;
|
|
2914
2914
|
const dispatchStatus = context.dispatchStatus ?? 'unknown';
|
|
2915
|
+
const dispatchFailure = context.dispatchFailureClassification ?? null;
|
|
2915
2916
|
const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
|
|
2916
2917
|
let outcome = 'needs_revision';
|
|
2917
2918
|
|
|
2918
|
-
if (dispatchStatus === '
|
|
2919
|
+
if (dispatchStatus === 'runtime_interrupted') {
|
|
2920
|
+
outcome = 'runtime_interrupted';
|
|
2921
|
+
reasons.push(`A recoverable runtime interruption stopped the worker${dispatchFailure?.category ? ` (${dispatchFailure.category})` : ''}.`);
|
|
2922
|
+
nextActions.push('Rotate the worker session and resume from the latest durable checkpoint without creating a development revision.');
|
|
2923
|
+
} else if (dispatchStatus === 'runtime_blocked') {
|
|
2924
|
+
outcome = 'runtime_blocked';
|
|
2925
|
+
reasons.push('Recoverable runtime interruption retries were exhausted.');
|
|
2926
|
+
nextActions.push('Inspect the runtime transport/session failure before resuming the project.');
|
|
2927
|
+
} else if (dispatchStatus === 'blocked_preflight') {
|
|
2919
2928
|
outcome = 'blocked';
|
|
2920
2929
|
reasons.push('Preflight blocked the task before development dispatch.');
|
|
2921
2930
|
nextActions.push('Resolve preflight findings and rerun the queue task.');
|
|
@@ -3031,6 +3040,8 @@ function queueStatusFromFinalJudgement(currentStatus, finalJudgement) {
|
|
|
3031
3040
|
const outcome = finalJudgement.judgement.outcome;
|
|
3032
3041
|
if (outcome === 'ready_to_apply') return currentStatus;
|
|
3033
3042
|
if (outcome === 'project_in_progress') return outcome;
|
|
3043
|
+
if (outcome === 'runtime_interrupted') return outcome;
|
|
3044
|
+
if (outcome === 'runtime_blocked') return outcome;
|
|
3034
3045
|
if (outcome === 'ready_for_human_review') return outcome;
|
|
3035
3046
|
return outcome;
|
|
3036
3047
|
}
|
|
@@ -7827,7 +7838,16 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
|
|
|
7827
7838
|
'用户限制'
|
|
7828
7839
|
];
|
|
7829
7840
|
|
|
7830
|
-
|
|
7841
|
+
const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
7842
|
+
{ category: 'compaction_timeout', pattern: 'compaction timed out' },
|
|
7843
|
+
{ category: 'compaction_timeout', pattern: 'transcript compaction failed' },
|
|
7844
|
+
{ category: 'transport_error', pattern: 'connection reset' },
|
|
7845
|
+
{ category: 'transport_error', pattern: 'socket hang up' },
|
|
7846
|
+
{ category: 'transport_error', pattern: 'network error' },
|
|
7847
|
+
{ category: 'rate_limit', pattern: 'rate limit' }
|
|
7848
|
+
];
|
|
7849
|
+
|
|
7850
|
+
export function dispatchFailureClassification(result, retry = {}) {
|
|
7831
7851
|
if (!result || result.exitCode === 0) {
|
|
7832
7852
|
return {
|
|
7833
7853
|
category: 'ok',
|
|
@@ -7839,6 +7859,7 @@ function dispatchFailureClassification(result, retry = {}) {
|
|
|
7839
7859
|
return {
|
|
7840
7860
|
category: 'timeout',
|
|
7841
7861
|
requiresHumanAction: false,
|
|
7862
|
+
recoverableRuntime: true,
|
|
7842
7863
|
matchedPattern: null
|
|
7843
7864
|
};
|
|
7844
7865
|
}
|
|
@@ -7849,12 +7870,24 @@ function dispatchFailureClassification(result, retry = {}) {
|
|
|
7849
7870
|
return {
|
|
7850
7871
|
category: 'requires_human_action',
|
|
7851
7872
|
requiresHumanAction: true,
|
|
7873
|
+
recoverableRuntime: false,
|
|
7852
7874
|
matchedPattern: matched
|
|
7853
7875
|
};
|
|
7854
7876
|
}
|
|
7877
|
+
const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
|
|
7878
|
+
const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
|
|
7879
|
+
if (runtimeMatch) {
|
|
7880
|
+
return {
|
|
7881
|
+
category: runtimeMatch.category ?? 'runtime_interruption',
|
|
7882
|
+
requiresHumanAction: false,
|
|
7883
|
+
recoverableRuntime: true,
|
|
7884
|
+
matchedPattern: runtimeMatch.pattern ?? runtimeMatch
|
|
7885
|
+
};
|
|
7886
|
+
}
|
|
7855
7887
|
return {
|
|
7856
7888
|
category: 'retryable_failure',
|
|
7857
7889
|
requiresHumanAction: false,
|
|
7890
|
+
recoverableRuntime: false,
|
|
7858
7891
|
matchedPattern: null
|
|
7859
7892
|
};
|
|
7860
7893
|
}
|
|
@@ -8264,6 +8297,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8264
8297
|
const runContext = {
|
|
8265
8298
|
env: {
|
|
8266
8299
|
...taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
|
|
8300
|
+
LOOP_SESSION_GENERATION: String(task.runtimeSessionGeneration ?? 0),
|
|
8267
8301
|
LOOP_HUMAN_INPUT_CONTEXT_FILE: humanInputContextFile,
|
|
8268
8302
|
LOOP_HUMAN_INPUT_CONTEXT_FILE_REL: path.relative(root, humanInputContextFile)
|
|
8269
8303
|
},
|
|
@@ -8312,9 +8346,17 @@ export async function runQueueOnce(root, options) {
|
|
|
8312
8346
|
const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
|
|
8313
8347
|
finalStatus = verifyOk ? 'completed' : 'verify_failed';
|
|
8314
8348
|
} else {
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8349
|
+
if (dispatch?.exitCode === 0) {
|
|
8350
|
+
finalStatus = 'completed';
|
|
8351
|
+
} else if (dispatchClassification?.requiresHumanAction) {
|
|
8352
|
+
finalStatus = 'needs_human_input';
|
|
8353
|
+
} else if (dispatchClassification?.recoverableRuntime) {
|
|
8354
|
+
const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
|
|
8355
|
+
const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
|
|
8356
|
+
finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
|
|
8357
|
+
} else {
|
|
8358
|
+
finalStatus = 'failed';
|
|
8359
|
+
}
|
|
8318
8360
|
}
|
|
8319
8361
|
}
|
|
8320
8362
|
worktreeInspection = await inspectWorktree(worktree);
|
|
@@ -8361,6 +8403,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8361
8403
|
});
|
|
8362
8404
|
if (!finalJudgement) finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
|
|
8363
8405
|
dispatchStatus: finalStatus,
|
|
8406
|
+
dispatchFailureClassification: dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null,
|
|
8364
8407
|
verificationFailed: verification.some((entry) => entry.result.exitCode !== 0)
|
|
8365
8408
|
});
|
|
8366
8409
|
progress.emit('final-judge', finalJudgement.judgement.outcome, `Final judgement: ${finalJudgement.judgement.outcome}`, {
|
|
@@ -8397,6 +8440,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8397
8440
|
if (!finalJudgement) {
|
|
8398
8441
|
finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
|
|
8399
8442
|
dispatchStatus: finalStatus,
|
|
8443
|
+
dispatchFailureClassification: dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null,
|
|
8400
8444
|
verificationFailed: verification.some((entry) => entry.result.exitCode !== 0)
|
|
8401
8445
|
});
|
|
8402
8446
|
finalStatus = queueStatusFromFinalJudgement(finalStatus, finalJudgement);
|
|
@@ -8405,13 +8449,13 @@ export async function runQueueOnce(root, options) {
|
|
|
8405
8449
|
revisionRequest = await writeRevisionRequest(root, queue, task, taskContract, acceptancePlan, devPlan, finalJudgement, acceptanceReviews);
|
|
8406
8450
|
}
|
|
8407
8451
|
|
|
8408
|
-
destination =
|
|
8452
|
+
destination = ['project_in_progress', 'runtime_interrupted'].includes(finalStatus)
|
|
8409
8453
|
? queueSubdirFor(root, queue, 'inbox')
|
|
8410
8454
|
: finalStatus === 'completed'
|
|
8411
8455
|
? queueSubdirFor(root, queue, 'done')
|
|
8412
8456
|
: finalStatus === 'superseded' ? queueSubdirFor(root, queue, 'canceled')
|
|
8413
8457
|
: queueSubdirFor(root, queue, 'failed');
|
|
8414
|
-
exitCode = ['completed', 'superseded', 'project_in_progress'].includes(finalStatus) ? 0 : 1;
|
|
8458
|
+
exitCode = ['completed', 'superseded', 'project_in_progress', 'runtime_interrupted'].includes(finalStatus) ? 0 : 1;
|
|
8415
8459
|
|
|
8416
8460
|
const finishedAt = new Date().toISOString();
|
|
8417
8461
|
progress.emit('queue', finalStatus === 'completed' ? 'completed' : finalStatus === 'project_in_progress' ? 'continued' : 'needs_attention', `Task finished with status ${finalStatus}`, {
|
|
@@ -8421,7 +8465,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8421
8465
|
});
|
|
8422
8466
|
const completedTask = {
|
|
8423
8467
|
...task,
|
|
8424
|
-
status:
|
|
8468
|
+
status: ['project_in_progress', 'runtime_interrupted'].includes(finalStatus) ? 'queued' : finalStatus,
|
|
8425
8469
|
startedAt,
|
|
8426
8470
|
finishedAt,
|
|
8427
8471
|
attempts: (task.attempts ?? 0) + Math.max(dispatchAttempts.length, dispatch ? 1 : 0),
|
|
@@ -8430,6 +8474,19 @@ export async function runQueueOnce(root, options) {
|
|
|
8430
8474
|
if (finalStatus === 'project_in_progress') {
|
|
8431
8475
|
completedTask.projectContinuedAt = finishedAt;
|
|
8432
8476
|
completedTask.projectContinuationCount = (task.projectContinuationCount ?? 0) + 1;
|
|
8477
|
+
completedTask.runtimeRecoveryCount = 0;
|
|
8478
|
+
const sessionMaxTicks = Math.max(1, Number(options.retry?.sessionMaxTicks ?? 10));
|
|
8479
|
+
if (completedTask.projectContinuationCount % sessionMaxTicks === 0) {
|
|
8480
|
+
completedTask.runtimeSessionGeneration = Number(task.runtimeSessionGeneration ?? 0) + 1;
|
|
8481
|
+
completedTask.sessionRotatedAt = finishedAt;
|
|
8482
|
+
completedTask.sessionRotationReason = 'bounded_tick_limit';
|
|
8483
|
+
}
|
|
8484
|
+
}
|
|
8485
|
+
if (finalStatus === 'runtime_interrupted') {
|
|
8486
|
+
completedTask.runtimeRecoveryCount = Number(task.runtimeRecoveryCount ?? 0) + 1;
|
|
8487
|
+
completedTask.runtimeSessionGeneration = Number(task.runtimeSessionGeneration ?? 0) + 1;
|
|
8488
|
+
completedTask.runtimeInterruptedAt = finishedAt;
|
|
8489
|
+
completedTask.runtimeInterruption = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
|
|
8433
8490
|
}
|
|
8434
8491
|
const completedFile = path.join(destination, path.basename(activeFile));
|
|
8435
8492
|
await writeJson(completedFile, completedTask);
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { buildFinalJudgement, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
|
|
2
|
+
import { buildFinalJudgement, dispatchFailureClassification, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
|
|
3
3
|
|
|
4
4
|
const basePlan = { rubric: [], automation: [] };
|
|
5
5
|
const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: false, task_scope: 'scoped_task' };
|
|
@@ -16,6 +16,35 @@ const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: fal
|
|
|
16
16
|
assert.equal(selectEffectiveAcceptanceReviews(devPlan, reviews)[0].checkpointId, 'cp10');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
{
|
|
20
|
+
const classification = dispatchFailureClassification({
|
|
21
|
+
exitCode: 1,
|
|
22
|
+
timedOut: false,
|
|
23
|
+
stderr: 'CLI transcript compaction failed: Compaction timed out',
|
|
24
|
+
stdout: ''
|
|
25
|
+
});
|
|
26
|
+
assert.equal(classification.category, 'compaction_timeout');
|
|
27
|
+
assert.equal(classification.recoverableRuntime, true);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
32
|
+
const reviews = { reviews: [{ checkpointId: 'cp38', sequence: 38, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
|
|
33
|
+
const judgement = buildFinalJudgement(
|
|
34
|
+
{ ...baseContract, task_scope: 'project' },
|
|
35
|
+
basePlan,
|
|
36
|
+
devPlan,
|
|
37
|
+
{ count: 38 },
|
|
38
|
+
reviews,
|
|
39
|
+
{
|
|
40
|
+
dispatchStatus: 'runtime_interrupted',
|
|
41
|
+
dispatchFailureClassification: { category: 'compaction_timeout', recoverableRuntime: true }
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
assert.equal(judgement.outcome, 'runtime_interrupted');
|
|
45
|
+
assert.equal(judgement.reasons.some((reason) => reason.includes('development')), false);
|
|
46
|
+
}
|
|
47
|
+
|
|
19
48
|
{
|
|
20
49
|
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
21
50
|
const reviews = {
|
|
@@ -7,6 +7,10 @@ import path from 'node:path';
|
|
|
7
7
|
const root = await mkdtemp(path.join(tmpdir(), 'loop-openclaw-install-'));
|
|
8
8
|
const deliveryCapture = path.join(root, 'delivery.json');
|
|
9
9
|
const mockOpenClaw = path.join(root, 'mock-openclaw.mjs');
|
|
10
|
+
const mockSystemctl = path.join(root, 'mock-systemctl.mjs');
|
|
11
|
+
const systemctlCapture = path.join(root, 'systemctl-calls.jsonl');
|
|
12
|
+
process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
|
|
13
|
+
process.env.SYSTEMCTL_CAPTURE = systemctlCapture;
|
|
10
14
|
await writeFile(mockOpenClaw, `#!/usr/bin/env node
|
|
11
15
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
12
16
|
import path from 'node:path';
|
|
@@ -24,6 +28,11 @@ else {
|
|
|
24
28
|
}
|
|
25
29
|
`);
|
|
26
30
|
await chmod(mockOpenClaw, 0o755);
|
|
31
|
+
await writeFile(mockSystemctl, `#!/usr/bin/env node
|
|
32
|
+
import { appendFile } from 'node:fs/promises';
|
|
33
|
+
if (process.env.SYSTEMCTL_CAPTURE) await appendFile(process.env.SYSTEMCTL_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n');
|
|
34
|
+
`);
|
|
35
|
+
await chmod(mockSystemctl, 0o755);
|
|
27
36
|
const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
|
|
28
37
|
function run(args) {
|
|
29
38
|
return new Promise((resolve) => {
|
|
@@ -34,21 +43,39 @@ function run(args) {
|
|
|
34
43
|
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
35
44
|
});
|
|
36
45
|
}
|
|
37
|
-
const
|
|
46
|
+
const installBase = ['--root', root, '--queue', 'test-tasks', '--openclaw-bin', mockOpenClaw, '--systemctl-bin', mockSystemctl];
|
|
47
|
+
const plan = await run([...installBase, '--json']);
|
|
38
48
|
const planReport = JSON.parse(plan.stdout);
|
|
39
49
|
if (plan.code !== 0 || planReport.status !== 'plan_only' || planReport.workerAgent !== 'builder' || planReport.workerSelection !== 'only_available' || !planReport.workerValidated || planReport.createsWorkerAgent) throw new Error(`plan failed: ${plan.stderr}`);
|
|
40
|
-
const missingWorker = await run([
|
|
50
|
+
const missingWorker = await run([...installBase, '--worker-agent', 'missing', '--json']);
|
|
41
51
|
if (missingWorker.code === 0 || !missingWorker.stderr.includes('does not exist')) throw new Error('installer accepted a missing worker agent');
|
|
42
|
-
const install = await run([
|
|
52
|
+
const install = await run([...installBase, '--worker-agent', 'builder', '--confirm-install', '--json']);
|
|
43
53
|
if (install.code !== 0 || JSON.parse(install.stdout).status !== 'installed') throw new Error(`install failed: ${install.stderr}`);
|
|
44
54
|
const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/test-tasks.json'), 'utf8'));
|
|
45
55
|
if (queue.dispatcher !== 'node scripts/loops/openclaw-loop-dispatch.mjs') throw new Error('dispatcher was not installed');
|
|
56
|
+
if (queue.scheduler?.required !== true || queue.scheduler?.heartbeatMaxAgeMs !== 300000) throw new Error('required scheduler heartbeat was not installed');
|
|
46
57
|
const dispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
|
|
47
|
-
if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE')) throw new Error('worker, recursion guard,
|
|
58
|
+
if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE') || !dispatcher.includes('LOOP_SESSION_GENERATION') || !dispatcher.includes('-g${sessionGeneration}')) throw new Error('worker, recursion guard, amendment polling, or session generation missing');
|
|
59
|
+
if (queue.retry?.runtimeRecoveryMaxAttempts !== 2 || queue.retry?.sessionMaxTicks !== 10) throw new Error('bounded runtime recovery policy was not installed');
|
|
48
60
|
const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
49
61
|
if (!instructions.includes('走 loop') || !instructions.includes('immediately execute')) throw new Error('conversation instructions missing');
|
|
50
62
|
const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
|
|
51
|
-
if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('只入队')) throw new Error('supersede/amend routing, live progress, async notification, or queue-only routing missing');
|
|
63
|
+
if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('只入队')) throw new Error('supersede/amend routing, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
|
|
64
|
+
const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.service');
|
|
65
|
+
const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.timer');
|
|
66
|
+
const service = await readFile(serviceFile, 'utf8');
|
|
67
|
+
const timer = await readFile(timerFile, 'utf8');
|
|
68
|
+
if (!service.includes('scheduler-tick') || !timer.includes('OnUnitActiveSec=1min')) throw new Error('scheduler systemd units were not installed');
|
|
69
|
+
const installSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
70
|
+
if (!installSystemctlCalls.includes('["--user","enable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not enabled');
|
|
71
|
+
const schedulerTick = await new Promise((resolve) => {
|
|
72
|
+
const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'scheduler-tick', '--force-due', '--plan-only'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
73
|
+
let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
74
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
75
|
+
});
|
|
76
|
+
if (schedulerTick.code !== 0) throw new Error(`installed scheduler tick failed: ${schedulerTick.stderr || schedulerTick.stdout}`);
|
|
77
|
+
const schedulerState = JSON.parse(await readFile(path.join(root, 'runtime/loops/test-tasks/scheduler/state.json'), 'utf8'));
|
|
78
|
+
if (!schedulerState.generatedAt || !schedulerState.nextRunAt) throw new Error('installed scheduler tick did not persist its heartbeat and cadence');
|
|
52
79
|
const notifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
|
|
53
80
|
if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
|
|
54
81
|
const delivery = await new Promise((resolve) => {
|
|
@@ -100,7 +127,7 @@ for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/lo
|
|
|
100
127
|
});
|
|
101
128
|
if (check.code !== 0) throw new Error(`generated script syntax failed: ${generated}: ${check.stderr}`);
|
|
102
129
|
}
|
|
103
|
-
const conflict = await run([
|
|
130
|
+
const conflict = await run([...installBase, '--worker-agent', 'builder', '--confirm-install', '--json']);
|
|
104
131
|
if (conflict.code === 0) throw new Error('installer overwrote existing files without --force');
|
|
105
132
|
const manager = new URL('./openclaw-manage.mjs', import.meta.url).pathname;
|
|
106
133
|
async function manage(args) {
|
|
@@ -126,4 +153,7 @@ const uninstall = await manage(['--action', 'uninstall', '--confirm-uninstall'])
|
|
|
126
153
|
if (uninstall.code !== 0 || JSON.parse(uninstall.stdout).status !== 'uninstalled') throw new Error(`uninstall failed: ${uninstall.stderr}`);
|
|
127
154
|
if (!await readFile(path.join(root, 'runtime/loops/test-tasks/state.json'), 'utf8').catch(() => 'retained')) throw new Error('unexpected runtime cleanup result');
|
|
128
155
|
if (await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8').then(() => true).catch(() => false)) throw new Error('managed wrapper survived uninstall');
|
|
156
|
+
if (await readFile(serviceFile, 'utf8').then(() => true).catch(() => false) || await readFile(timerFile, 'utf8').then(() => true).catch(() => false)) throw new Error('managed scheduler units survived uninstall');
|
|
157
|
+
const finalSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
158
|
+
if (!finalSystemctlCalls.includes('["--user","disable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not disabled during uninstall');
|
|
129
159
|
console.log('openclaw installer self-test passed');
|
|
@@ -5,13 +5,14 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
7
|
function parseArgs(argv) {
|
|
8
|
-
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', json: false, confirmInstall: false, force: false };
|
|
8
|
+
const out = { root: process.cwd(), queue: 'agent-tasks', workerAgent: null, openclawBin: 'openclaw', systemctlBin: 'systemctl', json: false, confirmInstall: false, force: false };
|
|
9
9
|
for (let i = 0; i < argv.length; i++) {
|
|
10
10
|
const arg = argv[i];
|
|
11
11
|
if (arg === '--root') out.root = path.resolve(argv[++i]);
|
|
12
12
|
else if (arg === '--queue') out.queue = argv[++i];
|
|
13
13
|
else if (arg === '--worker-agent') out.workerAgent = argv[++i];
|
|
14
14
|
else if (arg === '--openclaw-bin') out.openclawBin = argv[++i];
|
|
15
|
+
else if (arg === '--systemctl-bin') out.systemctlBin = argv[++i];
|
|
15
16
|
else if (arg === '--confirm-install') out.confirmInstall = true;
|
|
16
17
|
else if (arg === '--force') out.force = true;
|
|
17
18
|
else if (arg === '--json') out.json = true;
|
|
@@ -21,6 +22,10 @@ function parseArgs(argv) {
|
|
|
21
22
|
return out;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
function systemdEscape(value) {
|
|
26
|
+
return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
function safeId(value, label) {
|
|
25
30
|
if (!/^[a-zA-Z0-9._-]+$/.test(value)) throw new Error(`${label} contains unsupported characters.`);
|
|
26
31
|
return value;
|
|
@@ -63,6 +68,7 @@ function dispatcherSource({ workerAgent, openclawBin }) {
|
|
|
63
68
|
import { readFile } from 'node:fs/promises';
|
|
64
69
|
import { spawn } from 'node:child_process';
|
|
65
70
|
const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
|
|
71
|
+
const sessionGeneration = Number.parseInt(process.env.LOOP_SESSION_GENERATION || '0', 10) || 0;
|
|
66
72
|
const prompt = [
|
|
67
73
|
'You are receiving an already loop-managed task.',
|
|
68
74
|
'Do not route or enqueue this task again, even if its quoted request contains a loop trigger.',
|
|
@@ -79,20 +85,20 @@ const prompt = [
|
|
|
79
85
|
].join('\\n');
|
|
80
86
|
const child = spawn(${JSON.stringify(openclawBin)}, [
|
|
81
87
|
'agent', '--agent', ${JSON.stringify(workerAgent)},
|
|
82
|
-
'--session-key', \`agent:${workerAgent}:loop-task-\${task.id}\`,
|
|
88
|
+
'--session-key', \`agent:${workerAgent}:loop-task-\${task.id}-g\${sessionGeneration}\`,
|
|
83
89
|
'--message', prompt, '--json', '--timeout', '1800'
|
|
84
90
|
], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
|
|
85
91
|
child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); });
|
|
86
92
|
`;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
function wrapperSource({ queue }) {
|
|
95
|
+
function wrapperSource({ queue, loopBin }) {
|
|
90
96
|
return `#!/usr/bin/env node
|
|
91
97
|
import { spawn } from 'node:child_process';
|
|
92
98
|
const [command, ...rest] = process.argv.slice(2);
|
|
93
99
|
function run(args) {
|
|
94
100
|
return new Promise((resolve) => {
|
|
95
|
-
const child = spawn(
|
|
101
|
+
const child = spawn(process.execPath, [${JSON.stringify(loopBin)}, ...args], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
|
|
96
102
|
child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
97
103
|
});
|
|
98
104
|
}
|
|
@@ -121,6 +127,11 @@ if (command === 'route') {
|
|
|
121
127
|
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
122
128
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
123
129
|
process.exitCode = runCode || humanNotifyCode || terminalNotifyCode;
|
|
130
|
+
} else if (command === 'scheduler-tick') {
|
|
131
|
+
const tickCode = await run(['queue-scheduler-tick', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/openclaw-loop-notify.mjs', ...rest]);
|
|
132
|
+
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
133
|
+
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
134
|
+
process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;
|
|
124
135
|
} else {
|
|
125
136
|
console.error('Usage: node scripts/loops/openclaw-loop.mjs route --message "走 loop:任务" [source metadata]');
|
|
126
137
|
process.exitCode = 1;
|
|
@@ -128,6 +139,14 @@ if (command === 'route') {
|
|
|
128
139
|
`;
|
|
129
140
|
}
|
|
130
141
|
|
|
142
|
+
function schedulerServiceSource({ root, queue }) {
|
|
143
|
+
return `[Unit]\nDescription=Taskforce Loop Engineering scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory="${systemdEscape(root)}"\nExecStart="${systemdEscape(process.execPath)}" "${systemdEscape(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))}" scheduler-tick --json\n`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function schedulerTimerSource({ queue }) {
|
|
147
|
+
return `[Unit]\nDescription=Wake Taskforce Loop Engineering scheduler for ${queue}\n\n[Timer]\nOnBootSec=30s\nOnUnitActiveSec=1min\nAccuracySec=10s\nPersistent=true\nUnit=openclaw-loop-${queue}-scheduler.service\n\n[Install]\nWantedBy=timers.target\n`;
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
function notifierSource({ openclawBin }) {
|
|
132
151
|
return `#!/usr/bin/env node
|
|
133
152
|
import { spawn } from 'node:child_process';
|
|
@@ -164,13 +183,17 @@ function instructionsBlock({ queue }) {
|
|
|
164
183
|
async function main() {
|
|
165
184
|
const args = parseArgs(process.argv.slice(2));
|
|
166
185
|
if (args.help) {
|
|
167
|
-
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--openclaw-bin openclaw] [--confirm-install] [--force] [--json]');
|
|
186
|
+
console.log('Usage: loop-engineering-openclaw-install [--root workspace] [--queue agent-tasks] [--worker-agent agent-id] [--openclaw-bin openclaw] [--systemctl-bin systemctl] [--confirm-install] [--force] [--json]');
|
|
168
187
|
return;
|
|
169
188
|
}
|
|
170
189
|
safeId(args.queue, 'queue');
|
|
171
190
|
if (args.workerAgent) safeId(args.workerAgent, 'worker agent');
|
|
172
191
|
const worker = await resolveWorkerAgent(args);
|
|
173
192
|
args.workerAgent = worker.workerAgent;
|
|
193
|
+
args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
|
|
194
|
+
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
195
|
+
const schedulerUnit = `openclaw-loop-${args.queue}-scheduler.service`;
|
|
196
|
+
const schedulerTimer = `openclaw-loop-${args.queue}-scheduler.timer`;
|
|
174
197
|
const files = {
|
|
175
198
|
workspaceHealth: path.join(args.root, 'configs', 'loops', 'workspace-health.json'),
|
|
176
199
|
queueConfig: path.join(args.root, 'configs', 'loops', 'queues', `${args.queue}.json`),
|
|
@@ -178,11 +201,13 @@ async function main() {
|
|
|
178
201
|
wrapper: path.join(args.root, 'scripts', 'loops', 'openclaw-loop.mjs'),
|
|
179
202
|
notifier: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-notify.mjs'),
|
|
180
203
|
manifest: path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json'),
|
|
181
|
-
instructions: path.join(args.root, 'AGENTS.md')
|
|
204
|
+
instructions: path.join(args.root, 'AGENTS.md'),
|
|
205
|
+
schedulerService: path.join(systemdUserDir, schedulerUnit),
|
|
206
|
+
schedulerTimer: path.join(systemdUserDir, schedulerTimer)
|
|
182
207
|
};
|
|
183
208
|
const conflicts = [];
|
|
184
209
|
for (const [kind, file] of Object.entries(files)) if (!['instructions', 'workspaceHealth', 'manifest'].includes(kind) && await exists(file)) conflicts.push(path.relative(args.root, file));
|
|
185
|
-
const report = { version: 1, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, workerAgent: args.workerAgent, workerSelection: worker.selection, availableAgents: worker.availableAgents, workerValidated: true, createsWorkerAgent: false, openclawBin: args.openclawBin, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
210
|
+
const report = { version: 1, status: args.confirmInstall ? 'installed' : 'plan_only', readOnly: !args.confirmInstall, root: args.root, queue: args.queue, workerAgent: args.workerAgent, workerSelection: worker.selection, availableAgents: worker.availableAgents, workerValidated: true, createsWorkerAgent: false, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, scheduler: { required: true, unit: schedulerUnit, timer: schedulerTimer }, files: Object.fromEntries(Object.entries(files).map(([key, file]) => [key, path.relative(args.root, file)])), conflicts };
|
|
186
211
|
report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
|
|
187
212
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
188
213
|
if (args.confirmInstall) {
|
|
@@ -203,28 +228,42 @@ async function main() {
|
|
|
203
228
|
dispatcher: 'node scripts/loops/openclaw-loop-dispatch.mjs',
|
|
204
229
|
preflightConfig: 'configs/loops/workspace-health.json',
|
|
205
230
|
timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000,
|
|
206
|
-
|
|
231
|
+
scheduler: { required: true, heartbeatMaxAgeMs: 300000, initialInterval: '1m', minInterval: '1m', maxInterval: '4h', speedupFactor: 0.5, backoffFactor: 2, idleBackoffFactor: 2, humanGateBackoffFactor: 3, longRunHeadroomFactor: 1.25, jitter: '10s' },
|
|
232
|
+
retry: { maxAttempts: 1, runtimeRecoveryMaxAttempts: 2, sessionMaxTicks: 10, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] },
|
|
207
233
|
revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true }
|
|
208
234
|
}, null, 2)}\n`;
|
|
209
235
|
const dispatcherContent = dispatcherSource(args);
|
|
210
236
|
const wrapperContent = wrapperSource(args);
|
|
211
237
|
const notifierContent = notifierSource(args);
|
|
238
|
+
const schedulerServiceContent = schedulerServiceSource(args);
|
|
239
|
+
const schedulerTimerContent = schedulerTimerSource(args);
|
|
212
240
|
await writeFile(files.queueConfig, queueContent);
|
|
213
241
|
await writeFile(files.dispatcher, dispatcherContent);
|
|
214
242
|
await writeFile(files.wrapper, wrapperContent);
|
|
215
243
|
await writeFile(files.notifier, notifierContent);
|
|
244
|
+
await mkdir(systemdUserDir, { recursive: true });
|
|
245
|
+
await writeFile(files.schedulerService, schedulerServiceContent);
|
|
246
|
+
await writeFile(files.schedulerTimer, schedulerTimerContent);
|
|
247
|
+
const daemonReload = await run(args.systemctlBin, ['--user', 'daemon-reload'], { cwd: args.root });
|
|
248
|
+
if (daemonReload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(daemonReload.stderr || daemonReload.stdout).trim() || `exit ${daemonReload.code}`}`);
|
|
249
|
+
const enableTimer = await run(args.systemctlBin, ['--user', 'enable', '--now', schedulerTimer], { cwd: args.root });
|
|
250
|
+
if (enableTimer.code !== 0) throw new Error(`Cannot enable Loop scheduler timer ${schedulerTimer}: ${(enableTimer.stderr || enableTimer.stdout).trim() || `exit ${enableTimer.code}`}`);
|
|
216
251
|
const instructions = await exists(files.instructions) ? await readFile(files.instructions, 'utf8') : '';
|
|
217
252
|
const managedInstructions = instructionsBlock(args);
|
|
218
253
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
219
254
|
await mkdir(path.dirname(files.manifest), { recursive: true });
|
|
220
255
|
await writeFile(files.manifest, `${JSON.stringify({
|
|
221
|
-
version:
|
|
256
|
+
version: 2, queue: args.queue, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
|
|
222
257
|
managedFiles: [
|
|
223
258
|
{ path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
|
|
224
259
|
{ path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
|
|
225
260
|
{ path: path.relative(args.root, files.wrapper), sha256: sha256(wrapperContent) },
|
|
226
261
|
{ path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) }
|
|
227
262
|
],
|
|
263
|
+
managedUnits: [
|
|
264
|
+
{ path: files.schedulerService, unit: schedulerUnit, sha256: sha256(schedulerServiceContent) },
|
|
265
|
+
{ path: files.schedulerTimer, unit: schedulerTimer, sha256: sha256(schedulerTimerContent) }
|
|
266
|
+
],
|
|
228
267
|
managedInstructions: { path: 'AGENTS.md', sha256: sha256(managedInstructions), content: managedInstructions },
|
|
229
268
|
retainedOnUninstall: [`runtime/loops/${args.queue}`]
|
|
230
269
|
}, null, 2)}\n`);
|
|
@@ -6,6 +6,13 @@ import path from 'node:path';
|
|
|
6
6
|
|
|
7
7
|
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
8
|
async function exists(file) { try { await access(file); return true; } catch { return false; } }
|
|
9
|
+
function run(command, args, cwd) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
12
|
+
let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
|
|
13
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
9
16
|
function parseArgs(argv) {
|
|
10
17
|
const out = { root: process.cwd(), action: 'uninstall-plan', json: false, confirm: false };
|
|
11
18
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -36,15 +43,29 @@ async function main() {
|
|
|
36
43
|
const current = present ? await readFile(file, 'utf8') : '';
|
|
37
44
|
files.push({ path: entry.path, present, clean: present && sha256(current) === entry.sha256 });
|
|
38
45
|
}
|
|
46
|
+
const units = [];
|
|
47
|
+
for (const entry of manifest.managedUnits || []) {
|
|
48
|
+
const present = await exists(entry.path);
|
|
49
|
+
const current = present ? await readFile(entry.path, 'utf8') : '';
|
|
50
|
+
units.push({ ...entry, present, clean: present && sha256(current) === entry.sha256 });
|
|
51
|
+
}
|
|
39
52
|
const agentsFile = path.join(args.root, manifest.managedInstructions?.path || 'AGENTS.md');
|
|
40
53
|
const agentsText = await exists(agentsFile) ? await readFile(agentsFile, 'utf8') : '';
|
|
41
54
|
const block = manifest.managedInstructions?.content || '';
|
|
42
55
|
const instructionsClean = Boolean(block) && sha256(block) === manifest.managedInstructions?.sha256 && agentsText.includes(block);
|
|
43
|
-
const modified = files.filter((item) => item.present && !item.clean).map((item) => item.path);
|
|
44
|
-
const plan = { version:
|
|
56
|
+
const modified = [...files.filter((item) => item.present && !item.clean).map((item) => item.path), ...units.filter((item) => item.present && !item.clean).map((item) => item.path)];
|
|
57
|
+
const plan = { version: 2, action: args.action, readOnly: args.action.endsWith('-plan'), queue: manifest.queue, workerAgent: manifest.workerAgent, files, units, instructionsClean, modified, retained: manifest.retainedOnUninstall || [], ready: modified.length === 0 && instructionsClean };
|
|
45
58
|
if (args.action === 'uninstall') {
|
|
46
59
|
if (!plan.ready) throw new Error(`Refusing uninstall because managed content changed: ${[...modified, ...(!instructionsClean ? ['AGENTS.md managed block'] : [])].join(', ')}`);
|
|
60
|
+
const timer = units.find((item) => item.unit?.endsWith('.timer'));
|
|
61
|
+
if (timer) {
|
|
62
|
+
const stopped = await run(manifest.systemctlBin || 'systemctl', ['--user', 'disable', '--now', timer.unit], args.root);
|
|
63
|
+
if (stopped.code !== 0) throw new Error(`Cannot disable Loop scheduler timer ${timer.unit}: ${stopped.stderr || stopped.stdout}`);
|
|
64
|
+
}
|
|
47
65
|
for (const item of files) if (item.present && item.clean) await rm(path.join(args.root, item.path), { force: true });
|
|
66
|
+
for (const item of units) if (item.present && item.clean) await rm(item.path, { force: true });
|
|
67
|
+
const reload = await run(manifest.systemctlBin || 'systemctl', ['--user', 'daemon-reload'], args.root);
|
|
68
|
+
if (reload.code !== 0) throw new Error(`Cannot reload user systemd units: ${reload.stderr || reload.stdout}`);
|
|
48
69
|
await writeFile(agentsFile, agentsText.replace(block, ''));
|
|
49
70
|
await rm(manifestFile, { force: true });
|
|
50
71
|
plan.status = 'uninstalled'; plan.readOnly = false;
|
|
@@ -52,7 +73,7 @@ async function main() {
|
|
|
52
73
|
if (!plan.ready) throw new Error(`Refusing upgrade because managed content changed: ${[...modified, ...(!instructionsClean ? ['AGENTS.md managed block'] : [])].join(', ')}`);
|
|
53
74
|
const installer = new URL('./openclaw-install.mjs', import.meta.url).pathname;
|
|
54
75
|
const result = await new Promise((resolve) => {
|
|
55
|
-
const child = spawn(process.execPath, [installer, '--root', args.root, '--queue', manifest.queue, '--worker-agent', manifest.workerAgent, '--openclaw-bin', manifest.openclawBin || 'openclaw', '--confirm-install', '--force', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
76
|
+
const child = spawn(process.execPath, [installer, '--root', args.root, '--queue', manifest.queue, '--worker-agent', manifest.workerAgent, '--openclaw-bin', manifest.openclawBin || 'openclaw', '--systemctl-bin', manifest.systemctlBin || 'systemctl', '--confirm-install', '--force', '--json'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
56
77
|
let stdout = ''; let stderr = ''; child.stdout.on('data', (c) => { stdout += c; }); child.stderr.on('data', (c) => { stderr += c; });
|
|
57
78
|
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
58
79
|
});
|