taskforce-loop-engineering 0.8.3 → 0.8.5
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 +23 -0
- package/README.md +5 -0
- package/bin/loop-engineering.mjs +3 -1
- package/lib/core.mjs +76 -23
- package/package.json +1 -1
- package/scripts/openclaw-install-self-test.mjs +9 -1
- package/scripts/openclaw-install.mjs +7 -3
- package/scripts/route-notify-self-test.mjs +48 -1
- package/skills/taskforce-loop-engineering/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.5 - 2026-08-08
|
|
4
|
+
|
|
5
|
+
- Fixed generated systemd scheduler units so `WorkingDirectory` and `ExecStart` use unquoted, byte-safe systemd path escapes.
|
|
6
|
+
- Added real `systemd-analyze verify` coverage for scheduler paths containing spaces and non-ASCII characters.
|
|
7
|
+
|
|
8
|
+
## 0.8.4 - 2026-08-07
|
|
9
|
+
|
|
10
|
+
- Reclaim queue locks immediately when their recorded owner PID no longer
|
|
11
|
+
exists, even if the lease has not expired. Scheduler status no longer reports
|
|
12
|
+
those dead-owner locks as live, and an orphaned `active/` task forces recovery
|
|
13
|
+
on the next timer wake-up instead of waiting for scheduler backoff or lease
|
|
14
|
+
expiry.
|
|
15
|
+
|
|
16
|
+
- Distinguish one-time secret human inputs from durable non-sensitive decisions
|
|
17
|
+
and attestations, so review approvals remain available as structured evidence
|
|
18
|
+
while OTPs, passwords, tokens, and credential values are still destroyed after
|
|
19
|
+
consumption.
|
|
20
|
+
- Restrict human-gate reconciliation to the final judgement's effective
|
|
21
|
+
checkpoint set for every outcome, preventing a project-in-progress tick from
|
|
22
|
+
resurrecting historical waiting gates after a newer checkpoint clears them.
|
|
23
|
+
- Allow an explicitly superseded human gate to requeue a task from `waiting/`
|
|
24
|
+
through the normal queue CLI instead of requiring a manual file move.
|
|
25
|
+
|
|
3
26
|
## 0.8.3 - 2026-08-07
|
|
4
27
|
|
|
5
28
|
- Select the latest single-milestone checkpoint by durable checkpoint identity/sequence instead of regenerated acceptance-review timestamps, preventing a lexically late legacy `cp9` review from overriding a blocked `cp46+` checkpoint.
|
package/README.md
CHANGED
|
@@ -370,6 +370,11 @@ loop-engineering queue-human-input-resolve \
|
|
|
370
370
|
The human-input notifier scans active and terminal tasks, sends the concrete
|
|
371
371
|
checkpoint blocker, and records a `waiting_for_human` gate. Resolution is
|
|
372
372
|
idempotent; a terminal blocked task is requeued with the response attached.
|
|
373
|
+
OTP, password, token, credential, and verification-code gates are inferred as
|
|
374
|
+
one-time secrets and destroyed after consumption. Review decisions, approvals,
|
|
375
|
+
assignments, and attestations remain available in the gate event as durable
|
|
376
|
+
non-sensitive evidence. Use `--secret-input` or `--non-secret-input` to override
|
|
377
|
+
the inference when gate wording is ambiguous.
|
|
373
378
|
|
|
374
379
|
Goal-directed controllers can use the exported `normalizeGoalDecision`,
|
|
375
380
|
`goalLoopTransition`, and `goalStrategyFingerprint` helpers. They distinguish
|
package/bin/loop-engineering.mjs
CHANGED
|
@@ -154,6 +154,8 @@ function parseArgs(argv) {
|
|
|
154
154
|
else if (a === '--notify-command') args.notifyCommand = argv[++i];
|
|
155
155
|
else if (a === '--gate-id') args.gateId = argv[++i];
|
|
156
156
|
else if (a === '--input') args.input = argv[++i];
|
|
157
|
+
else if (a === '--secret-input') args.secretInput = true;
|
|
158
|
+
else if (a === '--non-secret-input') args.nonSecretInput = true;
|
|
157
159
|
else if (a === '--source-channel') args.sourceChannel = argv[++i];
|
|
158
160
|
else if (a === '--source-target') args.sourceTarget = argv[++i];
|
|
159
161
|
else if (a === '--source-account') args.sourceAccount = argv[++i];
|
|
@@ -2077,7 +2079,7 @@ async function queueRequeueCommand(args) {
|
|
|
2077
2079
|
if (!args.taskId) throw new Error('queue-requeue requires --task-id.');
|
|
2078
2080
|
const config = await loadQueueConfig(args.root, args.config);
|
|
2079
2081
|
const options = mergeQueueOptions(config, args);
|
|
2080
|
-
const result = await queueRequeue(args.root, options.queue, args.taskId);
|
|
2082
|
+
const result = await queueRequeue(args.root, options.queue, args.taskId, { from: args.from });
|
|
2081
2083
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
2082
2084
|
else console.log(`requeued ${result.taskId}: ${result.file}`);
|
|
2083
2085
|
return 0;
|
package/lib/core.mjs
CHANGED
|
@@ -1826,7 +1826,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1826
1826
|
const judgementFile = path.join(taskRuntimeDirFor(root, queue, taskId), 'final_judgement.json');
|
|
1827
1827
|
if (await exists(judgementFile)) {
|
|
1828
1828
|
const judgement = await readJson(judgementFile);
|
|
1829
|
-
const effectiveIds =
|
|
1829
|
+
const effectiveIds = Array.isArray(judgement?.coverage?.effective_review_ids)
|
|
1830
1830
|
? new Set(judgement.coverage.effective_review_ids)
|
|
1831
1831
|
: null;
|
|
1832
1832
|
if (effectiveIds?.size > 0) {
|
|
@@ -1932,28 +1932,40 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1932
1932
|
if (['resolved', 'consumed', 'satisfied'].includes(gate.status)) return { gate, outcome: 'already_resolved', ledger: path.relative(root, ledgerFile) };
|
|
1933
1933
|
const receivedAt = new Date().toISOString();
|
|
1934
1934
|
const response = options.input.trim();
|
|
1935
|
+
const requestText = `${gate.request ?? ''}\n${JSON.stringify(gate.needed ?? '')}`.toLowerCase();
|
|
1936
|
+
const inferredSecret = /(?:otp|one[- ]time|verification code|sms code|验证码|校验码|口令|password|api[ _-]?key|access[ _-]?token|secret|credential value|密钥|令牌)/i.test(requestText);
|
|
1937
|
+
const inputKind = options.secretInput === true
|
|
1938
|
+
? 'secret'
|
|
1939
|
+
: options.nonSecretInput === true
|
|
1940
|
+
? 'attestation'
|
|
1941
|
+
: inferredSecret ? 'secret' : 'attestation';
|
|
1942
|
+
const secretReceived = inputKind === 'secret';
|
|
1935
1943
|
const secretDir = path.join(queueDirFor(root, queue), 'human-input', 'secrets');
|
|
1936
1944
|
const eventsDir = path.join(queueDirFor(root, queue), 'human-input', 'events');
|
|
1937
|
-
await mkdir(secretDir, { recursive: true, mode: 0o700 });
|
|
1938
1945
|
await mkdir(eventsDir, { recursive: true });
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1946
|
+
let secretFile = null;
|
|
1947
|
+
if (secretReceived) {
|
|
1948
|
+
await mkdir(secretDir, { recursive: true, mode: 0o700 });
|
|
1949
|
+
secretFile = path.join(secretDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.secret`);
|
|
1950
|
+
const handle = await open(secretFile, 'wx', 0o600);
|
|
1951
|
+
try {
|
|
1952
|
+
await handle.writeFile(response);
|
|
1953
|
+
} finally {
|
|
1954
|
+
await handle.close();
|
|
1955
|
+
}
|
|
1945
1956
|
}
|
|
1946
1957
|
const responseSha256 = createHash('sha256').update(response).digest('hex');
|
|
1947
1958
|
const resolved = {
|
|
1948
1959
|
...gate,
|
|
1949
1960
|
status: 'resolved',
|
|
1950
|
-
|
|
1961
|
+
input_kind: inputKind,
|
|
1962
|
+
secret_received: secretReceived,
|
|
1951
1963
|
response_sha256: responseSha256,
|
|
1952
|
-
response_ref: path.relative(root, secretFile),
|
|
1964
|
+
...(secretReceived ? { response_ref: path.relative(root, secretFile) } : { response }),
|
|
1953
1965
|
response_message_id: options.sourceMessageId ?? null,
|
|
1954
1966
|
resolved_at: receivedAt
|
|
1955
1967
|
};
|
|
1956
|
-
delete resolved.response;
|
|
1968
|
+
if (secretReceived) delete resolved.response;
|
|
1957
1969
|
await writeJson(ledgerFile, resolved);
|
|
1958
1970
|
const eventFile = path.join(eventsDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.json`);
|
|
1959
1971
|
await writeJson(eventFile, {
|
|
@@ -1963,9 +1975,10 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1963
1975
|
task_id: taskId,
|
|
1964
1976
|
checkpoint_id: checkpointId,
|
|
1965
1977
|
status: 'pending_consumption',
|
|
1966
|
-
|
|
1978
|
+
input_kind: inputKind,
|
|
1979
|
+
secret_received: secretReceived,
|
|
1967
1980
|
response_sha256: responseSha256,
|
|
1968
|
-
response_ref: path.relative(root, secretFile),
|
|
1981
|
+
...(secretReceived ? { response_ref: path.relative(root, secretFile) } : { response }),
|
|
1969
1982
|
response_message_id: options.sourceMessageId ?? null,
|
|
1970
1983
|
created_at: receivedAt
|
|
1971
1984
|
});
|
|
@@ -1979,7 +1992,8 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1979
1992
|
humanInput: {
|
|
1980
1993
|
gate_id: options.gateId,
|
|
1981
1994
|
checkpoint_id: checkpointId,
|
|
1982
|
-
|
|
1995
|
+
input_kind: inputKind,
|
|
1996
|
+
secret_received: secretReceived,
|
|
1983
1997
|
response_sha256: responseSha256,
|
|
1984
1998
|
event: path.relative(root, eventFile),
|
|
1985
1999
|
received_at: receivedAt
|
|
@@ -2030,8 +2044,10 @@ async function prepareHumanInputContext(root, queue, taskId) {
|
|
|
2030
2044
|
checkpoint_id: consumed.checkpoint_id,
|
|
2031
2045
|
status: consumed.status,
|
|
2032
2046
|
secret_received: Boolean(consumed.secret_received),
|
|
2047
|
+
input_kind: consumed.input_kind ?? (consumed.secret_received ? 'secret' : 'attestation'),
|
|
2033
2048
|
response_sha256: consumed.response_sha256 ?? null,
|
|
2034
2049
|
response_ref: consumed.response_ref ? path.join(root, consumed.response_ref) : null,
|
|
2050
|
+
response: consumed.secret_received ? null : consumed.response ?? null,
|
|
2035
2051
|
resolved_at: consumed.resolved_at ?? null,
|
|
2036
2052
|
consumed_at: consumed.consumed_at ?? null
|
|
2037
2053
|
});
|
|
@@ -3191,6 +3207,7 @@ export async function queueStatus(root, queue) {
|
|
|
3191
3207
|
await ensureQueueDirs(root, queue);
|
|
3192
3208
|
const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
|
|
3193
3209
|
const lock = await readQueueLock(root, queue);
|
|
3210
|
+
const lockOwnerAlive = queueLockOwnerAlive(lock);
|
|
3194
3211
|
return {
|
|
3195
3212
|
queue,
|
|
3196
3213
|
queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
|
|
@@ -3200,8 +3217,10 @@ export async function queueStatus(root, queue) {
|
|
|
3200
3217
|
failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
|
|
3201
3218
|
canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
|
|
3202
3219
|
runs: (await listJson(queueSubdirFor(root, queue, 'runs'))).length,
|
|
3203
|
-
locked: Boolean(lock && Date.parse(lock.expiresAt) > Date.now()),
|
|
3204
|
-
lockExpiresAt: lock?.expiresAt ?? null
|
|
3220
|
+
locked: Boolean(lock && Date.parse(lock.expiresAt) > Date.now() && lockOwnerAlive),
|
|
3221
|
+
lockExpiresAt: lock?.expiresAt ?? null,
|
|
3222
|
+
lockPid: lock?.pid ?? null,
|
|
3223
|
+
lockOwnerAlive
|
|
3205
3224
|
};
|
|
3206
3225
|
}
|
|
3207
3226
|
|
|
@@ -3509,7 +3528,13 @@ export async function queueSchedulerTick(root, options) {
|
|
|
3509
3528
|
const previous = await readQueueSchedulerState(root, queue);
|
|
3510
3529
|
const previousProgress = await readQueueProgressState(root, queue);
|
|
3511
3530
|
const statusBefore = await queueStatus(root, queue);
|
|
3512
|
-
|
|
3531
|
+
// An active task without a live lock owner is an orphan. Do not let a
|
|
3532
|
+
// previously scheduled backoff postpone recovery until nextRunAt.
|
|
3533
|
+
const orphanedActive = statusBefore.active > 0 && !statusBefore.locked;
|
|
3534
|
+
const due = orphanedActive
|
|
3535
|
+
|| !previous?.nextRunAt
|
|
3536
|
+
|| Date.parse(previous.nextRunAt) <= nowMs
|
|
3537
|
+
|| Boolean(options.forceDue);
|
|
3513
3538
|
let runResult = null;
|
|
3514
3539
|
let executed = false;
|
|
3515
3540
|
let status = due ? 'due' : 'not_due';
|
|
@@ -3768,11 +3793,27 @@ async function readQueueLock(root, queue) {
|
|
|
3768
3793
|
}
|
|
3769
3794
|
}
|
|
3770
3795
|
|
|
3796
|
+
function processIsAlive(pid) {
|
|
3797
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
3798
|
+
try {
|
|
3799
|
+
process.kill(pid, 0);
|
|
3800
|
+
return true;
|
|
3801
|
+
} catch (err) {
|
|
3802
|
+
// EPERM means the process exists but this user cannot signal it.
|
|
3803
|
+
return Boolean(err && err.code === 'EPERM');
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
function queueLockOwnerAlive(lock) {
|
|
3808
|
+
return Boolean(lock && processIsAlive(lock.pid));
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3771
3811
|
async function acquireQueueLock(root, queue, leaseMs) {
|
|
3772
3812
|
const lockFile = path.join(queueDirFor(root, queue), 'queue.lock');
|
|
3773
3813
|
const now = Date.now();
|
|
3774
3814
|
const existing = await readQueueLock(root, queue);
|
|
3775
|
-
|
|
3815
|
+
const existingOwnerAlive = queueLockOwnerAlive(existing);
|
|
3816
|
+
if (existing && Date.parse(existing.expiresAt) > now && existingOwnerAlive) {
|
|
3776
3817
|
return { acquired: false, lock: existing };
|
|
3777
3818
|
}
|
|
3778
3819
|
if (existing) await rm(lockFile, { force: true });
|
|
@@ -3787,7 +3828,14 @@ async function acquireQueueLock(root, queue, leaseMs) {
|
|
|
3787
3828
|
try {
|
|
3788
3829
|
handle = await open(lockFile, 'wx');
|
|
3789
3830
|
await handle.writeFile(`${JSON.stringify(lock, null, 2)}\n`);
|
|
3790
|
-
return {
|
|
3831
|
+
return {
|
|
3832
|
+
acquired: true,
|
|
3833
|
+
lock,
|
|
3834
|
+
reclaimedLock: existing ? {
|
|
3835
|
+
lock: existing,
|
|
3836
|
+
reason: existingOwnerAlive ? 'expired_lease' : 'dead_owner_pid'
|
|
3837
|
+
} : null
|
|
3838
|
+
};
|
|
3791
3839
|
} catch (err) {
|
|
3792
3840
|
if (err && err.code === 'EEXIST') {
|
|
3793
3841
|
return { acquired: false, lock: await readQueueLock(root, queue) };
|
|
@@ -3863,7 +3911,9 @@ export async function queueCancel(root, queue, taskId, options = {}) {
|
|
|
3863
3911
|
export async function queueRequeue(root, queue, taskId, options = {}) {
|
|
3864
3912
|
const normalized = normalizeLoopId(queue);
|
|
3865
3913
|
await ensureQueueDirs(root, normalized);
|
|
3866
|
-
const
|
|
3914
|
+
const allowedSources = ['failed', 'active', 'waiting', 'canceled'];
|
|
3915
|
+
if (options.from && !allowedSources.includes(options.from)) throw new Error(`Unsupported requeue source: ${options.from}`);
|
|
3916
|
+
const found = await findTaskFile(root, normalized, taskId, options.from ? [options.from] : allowedSources);
|
|
3867
3917
|
if (!found) throw new Error(`Task not found in requeueable state: ${taskId}`);
|
|
3868
3918
|
const task = await readJson(found.file);
|
|
3869
3919
|
const inboxFile = path.join(queueSubdirFor(root, normalized, 'inbox'), path.basename(found.file));
|
|
@@ -7774,7 +7824,7 @@ async function recoverStaleActive(root, queue, staleActiveMs) {
|
|
|
7774
7824
|
return recovered;
|
|
7775
7825
|
}
|
|
7776
7826
|
|
|
7777
|
-
async function recoverOrphanActive(root, queue) {
|
|
7827
|
+
async function recoverOrphanActive(root, queue, recoveryReason = 'queue_lock_reacquired_with_active_task') {
|
|
7778
7828
|
const activeDir = queueSubdirFor(root, queue, 'active');
|
|
7779
7829
|
const inboxDir = queueSubdirFor(root, queue, 'inbox');
|
|
7780
7830
|
const files = await listJson(activeDir);
|
|
@@ -7793,7 +7843,7 @@ async function recoverOrphanActive(root, queue) {
|
|
|
7793
7843
|
orphanRecoveryCount: (task.orphanRecoveryCount ?? 0) + 1,
|
|
7794
7844
|
requeuedAt: recoveredAt,
|
|
7795
7845
|
requeuedFrom: 'active',
|
|
7796
|
-
recoveryReason
|
|
7846
|
+
recoveryReason
|
|
7797
7847
|
});
|
|
7798
7848
|
recovered.push({
|
|
7799
7849
|
taskId: task.id,
|
|
@@ -8193,7 +8243,10 @@ export async function runQueueOnce(root, options) {
|
|
|
8193
8243
|
// queue lease. Any task left in active/ is therefore an orphan from an
|
|
8194
8244
|
// interrupted parent runner. Requeue it immediately so existing
|
|
8195
8245
|
// checkpoints can be reviewed and execution can resume in this tick.
|
|
8196
|
-
const
|
|
8246
|
+
const recoveryReason = lockResult.reclaimedLock?.reason === 'dead_owner_pid'
|
|
8247
|
+
? 'dead_queue_lock_owner_pid'
|
|
8248
|
+
: 'queue_lock_reacquired_with_active_task';
|
|
8249
|
+
const orphanRecovered = await recoverOrphanActive(root, queue, recoveryReason);
|
|
8197
8250
|
if (orphanRecovered.length > 0) {
|
|
8198
8251
|
progress.emit('queue', 'orphan_recovered', `Recovered ${orphanRecovered.length} orphan active task(s)`, {
|
|
8199
8252
|
queue,
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
const root = await mkdtemp(path.join(tmpdir(), 'loop
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop openclaw 安装-'));
|
|
8
8
|
const deliveryCapture = path.join(root, 'delivery.json');
|
|
9
9
|
const mockOpenClaw = path.join(root, 'mock-openclaw.mjs');
|
|
10
10
|
const mockSystemctl = path.join(root, 'mock-systemctl.mjs');
|
|
@@ -66,6 +66,14 @@ const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-
|
|
|
66
66
|
const service = await readFile(serviceFile, 'utf8');
|
|
67
67
|
const timer = await readFile(timerFile, 'utf8');
|
|
68
68
|
if (!service.includes('scheduler-tick') || !timer.includes('OnUnitActiveSec=1min')) throw new Error('scheduler systemd units were not installed');
|
|
69
|
+
if (service.includes('WorkingDirectory="') || service.includes('ExecStart="') || !service.includes('\\x20') || !service.includes('\\xe5\\xae\\x89\\xe8\\xa3\\x85')) throw new Error('scheduler service paths were not encoded with systemd path escapes');
|
|
70
|
+
const systemdVerify = await new Promise((resolve) => {
|
|
71
|
+
const child = spawn('systemd-analyze', ['verify', serviceFile, timerFile], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
72
|
+
let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
73
|
+
child.on('error', (error) => resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` }));
|
|
74
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
75
|
+
});
|
|
76
|
+
if (systemdVerify.code !== 0) throw new Error(`systemd rejected generated scheduler units: ${systemdVerify.stderr || systemdVerify.stdout}`);
|
|
69
77
|
const installSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
70
78
|
if (!installSystemctlCalls.includes('["--user","enable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not enabled');
|
|
71
79
|
const schedulerTick = await new Promise((resolve) => {
|
|
@@ -22,8 +22,12 @@ function parseArgs(argv) {
|
|
|
22
22
|
return out;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function
|
|
26
|
-
return String(value)
|
|
25
|
+
function systemdEscapePath(value) {
|
|
26
|
+
return [...Buffer.from(String(value))]
|
|
27
|
+
.map((byte) => /[A-Za-z0-9/_.:-]/.test(String.fromCharCode(byte))
|
|
28
|
+
? String.fromCharCode(byte)
|
|
29
|
+
: `\\x${byte.toString(16).padStart(2, '0')}`)
|
|
30
|
+
.join('');
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
function safeId(value, label) {
|
|
@@ -140,7 +144,7 @@ if (command === 'route') {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
function schedulerServiceSource({ root, queue }) {
|
|
143
|
-
return `[Unit]\nDescription=Taskforce Loop Engineering scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory
|
|
147
|
+
return `[Unit]\nDescription=Taskforce Loop Engineering scheduler for ${queue}\nAfter=default.target\n\n[Service]\nType=oneshot\nWorkingDirectory=${systemdEscapePath(root)}\nExecStart=${systemdEscapePath(process.execPath)} ${systemdEscapePath(path.join(root, 'scripts', 'loops', 'openclaw-loop.mjs'))} scheduler-tick --json\n`;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
function schedulerTimerSource({ queue }) {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
notifyHumanInputRequests,
|
|
12
12
|
notifyTerminalTasks,
|
|
13
13
|
queueHumanDecision,
|
|
14
|
+
queueDirFor,
|
|
14
15
|
queueStatus,
|
|
15
16
|
queueSubdirFor,
|
|
16
17
|
readJson,
|
|
@@ -92,6 +93,14 @@ const orphanInbox = path.join(queueSubdirFor(root, orphanQueue, 'inbox'), orphan
|
|
|
92
93
|
const orphanActive = path.join(queueSubdirFor(root, orphanQueue, 'active'), orphanName);
|
|
93
94
|
await writeJson(orphanActive, { ...(await readJson(orphanInbox)), status: 'active', startedAt: new Date().toISOString() });
|
|
94
95
|
await rm(orphanInbox, { force: true });
|
|
96
|
+
// A future lease owned by a dead PID must not hide the orphan until expiry.
|
|
97
|
+
await writeJson(path.join(queueDirFor(root, orphanQueue), 'queue.lock'), {
|
|
98
|
+
version: 1,
|
|
99
|
+
queue: orphanQueue,
|
|
100
|
+
pid: 2_147_483_647,
|
|
101
|
+
acquiredAt: new Date().toISOString(),
|
|
102
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString()
|
|
103
|
+
});
|
|
95
104
|
await writeJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'), {
|
|
96
105
|
version: 1,
|
|
97
106
|
task_id: orphanRouted.task.id,
|
|
@@ -118,6 +127,7 @@ assert.ok(orphanRun.progress.some((event) => event.status === 'orphan_recovered'
|
|
|
118
127
|
const orphanTerminal = await readJson(path.join(root, orphanRun.taskPath));
|
|
119
128
|
assert.equal(orphanTerminal.orphanRecoveryCount, 1);
|
|
120
129
|
assert.equal(orphanTerminal.requeuedFrom, 'active');
|
|
130
|
+
assert.equal(orphanTerminal.recoveryReason, 'dead_queue_lock_owner_pid');
|
|
121
131
|
assert.equal((await readJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'))).checkpoint_id, 'cp-before-crash');
|
|
122
132
|
|
|
123
133
|
const reviewQueue = 'human-review-smoke';
|
|
@@ -373,7 +383,7 @@ assert.equal(gateSent.sent, 1);
|
|
|
373
383
|
const gateId = gateSent.results[0].gateId;
|
|
374
384
|
assert.equal((await readJson(path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(failedFile)))).status, 'waiting_for_human');
|
|
375
385
|
await assert.rejects(access(failedFile));
|
|
376
|
-
const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1' });
|
|
386
|
+
const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1', secretInput: true });
|
|
377
387
|
assert.equal(resolved.outcome, 'resolved_and_requeued');
|
|
378
388
|
const requeuedTask = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
|
|
379
389
|
assert.equal(requeuedTask.humanInput.secret_received, true);
|
|
@@ -382,6 +392,43 @@ const resolvedGate = await readJson(path.join(root, resolved.ledger ?? gateSent.
|
|
|
382
392
|
assert.equal(JSON.stringify(resolvedGate).includes('123456'), false);
|
|
383
393
|
assert.equal(resolvedGate.response_sha256.length, 64);
|
|
384
394
|
|
|
395
|
+
// Non-sensitive decisions and attestations remain available to the next worker
|
|
396
|
+
// tick instead of being destroyed like OTPs and credentials.
|
|
397
|
+
const attestationCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-attestation.json');
|
|
398
|
+
await writeJson(attestationCheckpoint, {
|
|
399
|
+
version: 1, task_id: routed.task.id, checkpoint_id: 'cp-attestation', status: 'needs_human_input',
|
|
400
|
+
blockers: [{ id: 'review-decision', reason: 'Provide the independent review decision.' }],
|
|
401
|
+
verification: [], risks: [], next_action: 'wait'
|
|
402
|
+
});
|
|
403
|
+
await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
404
|
+
const attestationGateId = `${routed.task.id}:cp-attestation`;
|
|
405
|
+
const attestationResolved = await resolveHumanInput(root, {
|
|
406
|
+
queue,
|
|
407
|
+
gateId: attestationGateId,
|
|
408
|
+
input: 'Reviewer accepts the candidate for the next gated stage.'
|
|
409
|
+
});
|
|
410
|
+
assert.equal(attestationResolved.gate.input_kind, 'attestation');
|
|
411
|
+
assert.equal(attestationResolved.gate.secret_received, false);
|
|
412
|
+
assert.equal(attestationResolved.gate.response, 'Reviewer accepts the candidate for the next gated stage.');
|
|
413
|
+
assert.equal(attestationResolved.gate.response_ref, undefined);
|
|
414
|
+
|
|
415
|
+
// A project-in-progress judgement with a newer effective checkpoint must not
|
|
416
|
+
// resurrect historical waiting gates from older checkpoints.
|
|
417
|
+
const attestationDoneCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-attestation-done.json');
|
|
418
|
+
await writeJson(attestationDoneCheckpoint, {
|
|
419
|
+
version: 1, task_id: routed.task.id, checkpoint_id: 'cp-attestation-done',
|
|
420
|
+
revises_checkpoint_id: 'cp-attestation', sequence: 2, status: 'ready_for_acceptance',
|
|
421
|
+
blockers: [], verification: [{ observation: 'attestation', result: 'accepted' }], risks: [], next_action: 'continue'
|
|
422
|
+
});
|
|
423
|
+
const finalJudgementFile = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'final_judgement.json');
|
|
424
|
+
await writeJson(finalJudgementFile, {
|
|
425
|
+
version: 1, task_id: routed.task.id, outcome: 'project_in_progress',
|
|
426
|
+
coverage: { effective_review_ids: ['cp-attestation-done'] }
|
|
427
|
+
});
|
|
428
|
+
const noHistoricalGateReplay = await notifyHumanInputRequests(root, { queue, dryRun: true });
|
|
429
|
+
assert.equal(noHistoricalGateReplay.inspected, 0);
|
|
430
|
+
await rm(finalJudgementFile, { force: true });
|
|
431
|
+
|
|
385
432
|
// Queued tasks receive a non-sensitive event reference without being moved.
|
|
386
433
|
const queuedCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-queued.json');
|
|
387
434
|
await writeJson(queuedCheckpoint, {
|
|
@@ -216,7 +216,7 @@ loop-engineering queue-revision-next --root <workspace> --queue <queue> --task-i
|
|
|
216
216
|
loop-engineering queue-lineage --root <workspace> --queue <queue> --task-id <id>
|
|
217
217
|
loop-engineering queue-lineage-bundle --root <workspace> --queue <queue> --task-id <id>
|
|
218
218
|
loop-engineering queue-human-decision --root <workspace> --queue <queue> --task-id <id> --decision approve|request_changes|reject
|
|
219
|
-
loop-engineering queue-human-input-resolve --root <workspace> --queue <queue> --gate-id <task:checkpoint> --input "<response>"
|
|
219
|
+
loop-engineering queue-human-input-resolve --root <workspace> --queue <queue> --gate-id <task:checkpoint> --input "<response>" [--secret-input|--non-secret-input]
|
|
220
220
|
loop-engineering queue-terminal-notify --root <workspace> --queue <queue> (--notify-command "<command>" | --dry-run)
|
|
221
221
|
loop-engineering queue-human-input-notify --root <workspace> --queue <queue> (--notify-command "<command>" | --dry-run)
|
|
222
222
|
```
|