taskforce-loop-engineering 0.7.2 → 0.8.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/CHANGELOG.md +16 -0
- package/MIGRATING.md +1 -0
- package/README.md +11 -4
- package/lib/core.mjs +313 -32
- package/package.json +2 -2
- package/scripts/final-judgement-self-test.mjs +70 -0
- package/scripts/openclaw-install-self-test.mjs +34 -5
- package/scripts/openclaw-install.mjs +45 -7
- package/scripts/openclaw-manage.mjs +24 -3
- package/scripts/route-notify-self-test.mjs +81 -4
- package/scripts/scheduler-heartbeat-self-test.mjs +46 -0
- package/skills/taskforce-loop-engineering/SKILL.md +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.1 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
- 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`.
|
|
6
|
+
- Make uninstall disable and remove the managed scheduler units, while retaining queue runtime and refusing to overwrite locally modified managed units.
|
|
7
|
+
|
|
8
|
+
## 0.8.0 - 2026-08-07
|
|
9
|
+
|
|
10
|
+
- Add project-aware completion semantics: accepted milestones return project tasks to `inbox/` as `project_in_progress` until an explicit project terminal contract is accepted.
|
|
11
|
+
- Replace filename/tail-based checkpoint judgement with milestone lineage, revision ancestry, sequence, and recency so resolved historical blockers and `cp10` ordering cannot corrupt the final judgement.
|
|
12
|
+
- Separate current blockers from `deferred_gates`, and preserve future authorization boundaries without blocking safe local backlog work.
|
|
13
|
+
- Add a durable human-input lifecycle with a distinct `waiting/` queue state. Inputs received while queued, active, failed, or canceled are delivered on the next safe tick, consumed once, and closed by a successor checkpoint.
|
|
14
|
+
- Keep one-time secrets out of task bodies and ordinary JSON artifacts. Store them in permission-restricted temporary files, pass only references and hashes, and destroy plaintext after dispatch.
|
|
15
|
+
- Recover orphaned `active/` tasks immediately after a new runner acquires the queue lock. The task is atomically returned to `inbox/`, recovery metadata is retained, and the same tick resumes from durable checkpoints instead of leaving a zombie active task until the stale timeout.
|
|
16
|
+
- Add required scheduler heartbeat health checks. A queue with `scheduler.required=true` and queued work now fails `doctor` with `scheduler_missing` when no fresh external scheduler tick has been observed.
|
|
17
|
+
- Add regression coverage for project continuation, checkpoint lineage and ordering, human-input state transitions and redaction, orphan recovery, and scheduler heartbeat fail-closed behavior.
|
|
18
|
+
|
|
3
19
|
## 0.7.2 - 2026-08-06
|
|
4
20
|
|
|
5
21
|
- Keep `ready_for_human_review` tasks out of `done/` until an explicit human decision is recorded; emit a scoped acceptance notification, fail closed when delivery routing is missing, and transition approved tasks to `completed` only after approval.
|
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
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
6
7
|
|
|
7
8
|
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
9
|
|
|
@@ -785,6 +786,24 @@ export async function doctorReport(root, options = {}) {
|
|
|
785
786
|
preflightConfig: optionsForQueue.preflightConfig ?? null,
|
|
786
787
|
status
|
|
787
788
|
});
|
|
789
|
+
if (config.scheduler?.required === true && status.queued > 0) {
|
|
790
|
+
const schedulerState = await readQueueSchedulerState(root, optionsForQueue.queue);
|
|
791
|
+
const heartbeatMaxAgeMs = parseScheduleDurationMs(
|
|
792
|
+
config.scheduler.heartbeatMaxAge ?? '5m',
|
|
793
|
+
'scheduler.heartbeatMaxAge'
|
|
794
|
+
);
|
|
795
|
+
const generatedAtMs = Date.parse(schedulerState?.generatedAt ?? '');
|
|
796
|
+
const ageMs = Number.isFinite(generatedAtMs) ? Math.max(0, Date.now() - generatedAtMs) : null;
|
|
797
|
+
const healthy = ageMs !== null && ageMs <= heartbeatMaxAgeMs;
|
|
798
|
+
add(`queue:${optionsForQueue.queue}:scheduler-heartbeat`, 'fail', healthy, {
|
|
799
|
+
code: healthy ? 'scheduler_healthy' : 'scheduler_missing',
|
|
800
|
+
queued: status.queued,
|
|
801
|
+
state: path.relative(root, queueSchedulerStatePath(root, optionsForQueue.queue)),
|
|
802
|
+
generatedAt: schedulerState?.generatedAt ?? null,
|
|
803
|
+
ageMs,
|
|
804
|
+
heartbeatMaxAgeMs
|
|
805
|
+
});
|
|
806
|
+
}
|
|
788
807
|
if (status.locked) add(`queue:${optionsForQueue.queue}:lock`, 'warn', false, status.lockExpiresAt);
|
|
789
808
|
if (status.active > 0) add(`queue:${optionsForQueue.queue}:active`, 'warn', false, `${status.active} active task(s)`);
|
|
790
809
|
if (status.failed > 0) add(`queue:${optionsForQueue.queue}:failed`, 'warn', false, `${status.failed} failed task(s)`);
|
|
@@ -924,7 +943,7 @@ export function queueSubdirFor(root, queue, subdir) {
|
|
|
924
943
|
|
|
925
944
|
export async function ensureQueueDirs(root, queue) {
|
|
926
945
|
normalizeLoopId(queue);
|
|
927
|
-
await Promise.all(['inbox', 'active', 'done', 'failed', 'runs', 'canceled', 'tasks']
|
|
946
|
+
await Promise.all(['inbox', 'active', 'waiting', 'done', 'failed', 'runs', 'canceled', 'tasks']
|
|
928
947
|
.map((subdir) => mkdir(queueSubdirFor(root, queue, subdir), { recursive: true })));
|
|
929
948
|
}
|
|
930
949
|
|
|
@@ -1422,14 +1441,15 @@ export async function projectStatus(root, options = {}) {
|
|
|
1422
1441
|
backlog = null;
|
|
1423
1442
|
}
|
|
1424
1443
|
const totals = queues.reduce((acc, queue) => {
|
|
1425
|
-
for (const key of ['queued', 'active', 'done', 'failed', 'canceled', 'runs']) {
|
|
1444
|
+
for (const key of ['queued', 'active', 'waiting', 'done', 'failed', 'canceled', 'runs']) {
|
|
1426
1445
|
acc[key] += queue.status[key] ?? 0;
|
|
1427
1446
|
}
|
|
1428
1447
|
if (queue.status.locked) acc.locked += 1;
|
|
1429
1448
|
return acc;
|
|
1430
|
-
}, { queued: 0, active: 0, done: 0, failed: 0, canceled: 0, runs: 0, locked: 0 });
|
|
1449
|
+
}, { queued: 0, active: 0, waiting: 0, done: 0, failed: 0, canceled: 0, runs: 0, locked: 0 });
|
|
1431
1450
|
const needsAttention = [];
|
|
1432
1451
|
if (totals.failed > 0) needsAttention.push('failed_tasks_present');
|
|
1452
|
+
if (totals.waiting > 0) needsAttention.push('human_input_waiting');
|
|
1433
1453
|
if (totals.active > 0) needsAttention.push('active_tasks_present');
|
|
1434
1454
|
if (queues.some((queue) => queue.status.locked)) needsAttention.push('queue_locked');
|
|
1435
1455
|
return {
|
|
@@ -1780,7 +1800,7 @@ function humanInputMessage(queue, task, checkpoint, gateId) {
|
|
|
1780
1800
|
|
|
1781
1801
|
async function tasksById(root, queue) {
|
|
1782
1802
|
const tasks = new Map();
|
|
1783
|
-
for (const subdir of ['inbox', 'active', 'done', 'failed', 'canceled']) {
|
|
1803
|
+
for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
|
|
1784
1804
|
for (const file of await listJson(queueSubdirFor(root, queue, subdir))) {
|
|
1785
1805
|
const task = await readJson(path.join(queueSubdirFor(root, queue, subdir), file));
|
|
1786
1806
|
if (task?.id) tasks.set(task.id, { task, subdir });
|
|
@@ -1810,6 +1830,19 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1810
1830
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
1811
1831
|
if (await exists(ledgerFile)) {
|
|
1812
1832
|
const gate = await readJson(ledgerFile);
|
|
1833
|
+
if (gate.status === 'waiting_for_human' && entry.subdir === 'inbox') {
|
|
1834
|
+
const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${safeTaskId(taskId)}.json`);
|
|
1835
|
+
if (await exists(inboxFile)) {
|
|
1836
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(inboxFile));
|
|
1837
|
+
await writeJson(waitingFile, {
|
|
1838
|
+
...entry.task,
|
|
1839
|
+
status: 'waiting_for_human',
|
|
1840
|
+
waitingGateId: gateId,
|
|
1841
|
+
waitingSince: gate.requested_at ?? new Date().toISOString()
|
|
1842
|
+
});
|
|
1843
|
+
await rm(inboxFile, { force: true });
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1813
1846
|
results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
1814
1847
|
continue;
|
|
1815
1848
|
}
|
|
@@ -1847,6 +1880,19 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1847
1880
|
requested_at: new Date().toISOString(),
|
|
1848
1881
|
notification: compactCommandResult(result)
|
|
1849
1882
|
});
|
|
1883
|
+
if (entry.subdir === 'inbox') {
|
|
1884
|
+
const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${safeTaskId(taskId)}.json`);
|
|
1885
|
+
if (await exists(inboxFile)) {
|
|
1886
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(inboxFile));
|
|
1887
|
+
await writeJson(waitingFile, {
|
|
1888
|
+
...entry.task,
|
|
1889
|
+
status: 'waiting_for_human',
|
|
1890
|
+
waitingGateId: gateId,
|
|
1891
|
+
waitingSince: new Date().toISOString()
|
|
1892
|
+
});
|
|
1893
|
+
await rm(inboxFile, { force: true });
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1850
1896
|
results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
|
|
1851
1897
|
}
|
|
1852
1898
|
}
|
|
@@ -1872,37 +1918,153 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1872
1918
|
const ledgerFile = path.join(queueDirFor(root, queue), 'human-input', 'gates', `${safeTaskId(taskId)}.${checkpointId}.json`);
|
|
1873
1919
|
if (!await exists(ledgerFile)) throw new Error(`Human-input gate not found: ${options.gateId}`);
|
|
1874
1920
|
const gate = await readJson(ledgerFile);
|
|
1875
|
-
if (
|
|
1921
|
+
if (['resolved', 'consumed', 'satisfied'].includes(gate.status)) return { gate, outcome: 'already_resolved', ledger: path.relative(root, ledgerFile) };
|
|
1922
|
+
const receivedAt = new Date().toISOString();
|
|
1923
|
+
const response = options.input.trim();
|
|
1924
|
+
const secretDir = path.join(queueDirFor(root, queue), 'human-input', 'secrets');
|
|
1925
|
+
const eventsDir = path.join(queueDirFor(root, queue), 'human-input', 'events');
|
|
1926
|
+
await mkdir(secretDir, { recursive: true, mode: 0o700 });
|
|
1927
|
+
await mkdir(eventsDir, { recursive: true });
|
|
1928
|
+
const secretFile = path.join(secretDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.secret`);
|
|
1929
|
+
const handle = await open(secretFile, 'wx', 0o600);
|
|
1930
|
+
try {
|
|
1931
|
+
await handle.writeFile(response);
|
|
1932
|
+
} finally {
|
|
1933
|
+
await handle.close();
|
|
1934
|
+
}
|
|
1935
|
+
const responseSha256 = createHash('sha256').update(response).digest('hex');
|
|
1876
1936
|
const resolved = {
|
|
1877
1937
|
...gate,
|
|
1878
1938
|
status: 'resolved',
|
|
1879
|
-
|
|
1939
|
+
secret_received: true,
|
|
1940
|
+
response_sha256: responseSha256,
|
|
1941
|
+
response_ref: path.relative(root, secretFile),
|
|
1880
1942
|
response_message_id: options.sourceMessageId ?? null,
|
|
1881
|
-
resolved_at:
|
|
1943
|
+
resolved_at: receivedAt
|
|
1882
1944
|
};
|
|
1945
|
+
delete resolved.response;
|
|
1883
1946
|
await writeJson(ledgerFile, resolved);
|
|
1884
|
-
const
|
|
1947
|
+
const eventFile = path.join(eventsDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.json`);
|
|
1948
|
+
await writeJson(eventFile, {
|
|
1949
|
+
version: 1,
|
|
1950
|
+
type: 'human_input_resolved',
|
|
1951
|
+
gate_id: options.gateId,
|
|
1952
|
+
task_id: taskId,
|
|
1953
|
+
checkpoint_id: checkpointId,
|
|
1954
|
+
status: 'pending_consumption',
|
|
1955
|
+
secret_received: true,
|
|
1956
|
+
response_sha256: responseSha256,
|
|
1957
|
+
response_ref: path.relative(root, secretFile),
|
|
1958
|
+
response_message_id: options.sourceMessageId ?? null,
|
|
1959
|
+
created_at: receivedAt
|
|
1960
|
+
});
|
|
1961
|
+
const found = await findTaskFile(root, queue, taskId, ['inbox', 'active', 'waiting', 'failed', 'canceled']);
|
|
1885
1962
|
let requeued = null;
|
|
1963
|
+
let updated = null;
|
|
1886
1964
|
if (found) {
|
|
1887
1965
|
const task = await readJson(found.file);
|
|
1888
|
-
const
|
|
1889
|
-
await writeJson(inboxFile, {
|
|
1966
|
+
const taskUpdate = {
|
|
1890
1967
|
...task,
|
|
1891
|
-
status: 'queued',
|
|
1892
|
-
body: `${task.body}\n\nHuman input for gate ${options.gateId}:\n${options.input.trim()}`,
|
|
1893
1968
|
humanInput: {
|
|
1894
1969
|
gate_id: options.gateId,
|
|
1895
1970
|
checkpoint_id: checkpointId,
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1971
|
+
secret_received: true,
|
|
1972
|
+
response_sha256: responseSha256,
|
|
1973
|
+
event: path.relative(root, eventFile),
|
|
1974
|
+
received_at: receivedAt
|
|
1975
|
+
}
|
|
1976
|
+
};
|
|
1977
|
+
if (['waiting', 'failed', 'canceled'].includes(found.subdir)) {
|
|
1978
|
+
const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(found.file));
|
|
1979
|
+
await writeJson(inboxFile, {
|
|
1980
|
+
...taskUpdate,
|
|
1981
|
+
status: 'queued',
|
|
1982
|
+
requeuedAt: receivedAt,
|
|
1983
|
+
requeuedFrom: found.subdir
|
|
1984
|
+
});
|
|
1985
|
+
await rm(found.file, { force: true });
|
|
1986
|
+
requeued = path.relative(root, inboxFile);
|
|
1987
|
+
} else if (found.subdir === 'inbox') {
|
|
1988
|
+
await writeJson(found.file, taskUpdate);
|
|
1989
|
+
updated = path.relative(root, found.file);
|
|
1990
|
+
} else {
|
|
1991
|
+
// An active dispatcher cannot be mutated safely. The durable event is
|
|
1992
|
+
// consumed on the next bounded tick after the active run finishes.
|
|
1993
|
+
updated = path.relative(root, eventFile);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
return {
|
|
1997
|
+
gate: resolved,
|
|
1998
|
+
outcome: requeued ? 'resolved_and_requeued' : found?.subdir === 'active' ? 'resolved_pending_safe_boundary' : 'resolved_for_next_tick',
|
|
1999
|
+
requeued,
|
|
2000
|
+
updated,
|
|
2001
|
+
event: path.relative(root, eventFile)
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
async function prepareHumanInputContext(root, queue, taskId) {
|
|
2006
|
+
const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
|
|
2007
|
+
const runtimeDir = taskRuntimeDirFor(root, queue, taskId);
|
|
2008
|
+
const contextFile = path.join(runtimeDir, 'human_input_context.json');
|
|
2009
|
+
const gates = [];
|
|
2010
|
+
for (const file of await listJson(gatesDir)) {
|
|
2011
|
+
const full = path.join(gatesDir, file);
|
|
2012
|
+
const gate = await readJson(full);
|
|
2013
|
+
if (gate.task_id !== taskId || !['resolved', 'consumed'].includes(gate.status)) continue;
|
|
2014
|
+
const consumedAt = gate.consumed_at ?? new Date().toISOString();
|
|
2015
|
+
const consumed = gate.status === 'resolved' ? { ...gate, status: 'consumed', consumed_at: consumedAt } : gate;
|
|
2016
|
+
if (gate.status === 'resolved') await writeJson(full, consumed);
|
|
2017
|
+
gates.push({
|
|
2018
|
+
gate_id: consumed.gate_id,
|
|
2019
|
+
checkpoint_id: consumed.checkpoint_id,
|
|
2020
|
+
status: consumed.status,
|
|
2021
|
+
secret_received: Boolean(consumed.secret_received),
|
|
2022
|
+
response_sha256: consumed.response_sha256 ?? null,
|
|
2023
|
+
response_ref: consumed.response_ref ? path.join(root, consumed.response_ref) : null,
|
|
2024
|
+
resolved_at: consumed.resolved_at ?? null,
|
|
2025
|
+
consumed_at: consumed.consumed_at ?? null
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
await writeJson(contextFile, { version: 1, task_id: taskId, gates });
|
|
2029
|
+
return contextFile;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
async function destroyConsumedHumanInputSecrets(root, queue, taskId) {
|
|
2033
|
+
const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
|
|
2034
|
+
for (const file of await listJson(gatesDir)) {
|
|
2035
|
+
const full = path.join(gatesDir, file);
|
|
2036
|
+
const gate = await readJson(full);
|
|
2037
|
+
if (gate.task_id !== taskId || gate.status !== 'consumed' || !gate.response_ref) continue;
|
|
2038
|
+
await rm(path.join(root, gate.response_ref), { force: true });
|
|
2039
|
+
await writeJson(full, {
|
|
2040
|
+
...gate,
|
|
2041
|
+
secret_destroyed: true,
|
|
2042
|
+
secret_destroyed_at: new Date().toISOString()
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
async function reconcileHumanInputGates(root, queue, taskId) {
|
|
2048
|
+
const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
|
|
2049
|
+
const checkpointsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'checkpoints');
|
|
2050
|
+
const checkpoints = [];
|
|
2051
|
+
for (const file of await listJson(checkpointsDir)) checkpoints.push(await readJson(path.join(checkpointsDir, file)));
|
|
2052
|
+
for (const file of await listJson(gatesDir)) {
|
|
2053
|
+
const full = path.join(gatesDir, file);
|
|
2054
|
+
const gate = await readJson(full);
|
|
2055
|
+
if (gate.task_id !== taskId || gate.status !== 'consumed') continue;
|
|
2056
|
+
const successor = checkpoints
|
|
2057
|
+
.filter((checkpoint) => checkpoint?.revises_checkpoint_id === gate.checkpoint_id)
|
|
2058
|
+
.sort((a, b) => Number(b.sequence ?? 0) - Number(a.sequence ?? 0))[0];
|
|
2059
|
+
if (!successor) continue;
|
|
2060
|
+
await writeJson(full, {
|
|
2061
|
+
...gate,
|
|
2062
|
+
status: 'satisfied',
|
|
2063
|
+
satisfied_at: new Date().toISOString(),
|
|
2064
|
+
satisfied_by_checkpoint_id: successor.checkpoint_id,
|
|
2065
|
+
successor_status: successor.status ?? null
|
|
1901
2066
|
});
|
|
1902
|
-
await rm(found.file, { force: true });
|
|
1903
|
-
requeued = path.relative(root, inboxFile);
|
|
1904
2067
|
}
|
|
1905
|
-
return { gate: resolved, outcome: requeued ? 'resolved_and_requeued' : 'resolved_pending_terminal', requeued };
|
|
1906
2068
|
}
|
|
1907
2069
|
|
|
1908
2070
|
export function taskRuntimeDirFor(root, queue, taskId) {
|
|
@@ -2033,6 +2195,10 @@ function buildTaskContract(queue, task, options = {}) {
|
|
|
2033
2195
|
const inferredRisk = inferTaskRisk(task);
|
|
2034
2196
|
const modelAssessed = task.riskAssessment === 'model_assessed';
|
|
2035
2197
|
const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
|
|
2198
|
+
const requestText = `${task.title ?? ''}\n${task.body ?? ''}`.toLowerCase();
|
|
2199
|
+
const taskScope = /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
|
|
2200
|
+
? 'project'
|
|
2201
|
+
: 'scoped_task';
|
|
2036
2202
|
return {
|
|
2037
2203
|
version: 1,
|
|
2038
2204
|
task_id: task.id,
|
|
@@ -2040,6 +2206,7 @@ function buildTaskContract(queue, task, options = {}) {
|
|
|
2040
2206
|
title: task.title,
|
|
2041
2207
|
original_request: task.body,
|
|
2042
2208
|
goal: task.body,
|
|
2209
|
+
task_scope: taskScope,
|
|
2043
2210
|
deliverables: [
|
|
2044
2211
|
'Structured run artifact',
|
|
2045
2212
|
'Concise completion summary',
|
|
@@ -2378,11 +2545,15 @@ function buildDevPlan(contract, acceptancePlan) {
|
|
|
2378
2545
|
version: 1,
|
|
2379
2546
|
task_id: contract.task_id,
|
|
2380
2547
|
checkpoint_id: firstCheckpointId,
|
|
2548
|
+
milestone_id: firstCheckpointId,
|
|
2549
|
+
revises_checkpoint_id: null,
|
|
2381
2550
|
status: 'ready_for_acceptance | blocked | needs_human_input',
|
|
2382
2551
|
summary: 'What changed and why.',
|
|
2383
2552
|
files_changed: [],
|
|
2384
2553
|
verification: [],
|
|
2385
2554
|
blockers: [],
|
|
2555
|
+
deferred_gates: [],
|
|
2556
|
+
project_completion: null,
|
|
2386
2557
|
risks: [],
|
|
2387
2558
|
next_action: 'acceptance_review'
|
|
2388
2559
|
},
|
|
@@ -2671,6 +2842,11 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
2671
2842
|
await writeJson(reviewFile, review);
|
|
2672
2843
|
reviews.push({
|
|
2673
2844
|
checkpointId: review.checkpoint_id,
|
|
2845
|
+
milestoneId: checkpoint.milestone_id ?? null,
|
|
2846
|
+
revisesCheckpointId: checkpoint.revises_checkpoint_id ?? null,
|
|
2847
|
+
sequence: Number(checkpoint.sequence ?? String(checkpoint.checkpoint_id ?? '').match(/(\d+)$/)?.[1] ?? 0),
|
|
2848
|
+
createdAt: checkpoint.created_at ?? review.created_at,
|
|
2849
|
+
projectCompletion: checkpoint.project_completion ?? null,
|
|
2674
2850
|
status: review.status,
|
|
2675
2851
|
file: path.relative(root, reviewFile)
|
|
2676
2852
|
});
|
|
@@ -2688,16 +2864,55 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
2688
2864
|
};
|
|
2689
2865
|
}
|
|
2690
2866
|
|
|
2691
|
-
function
|
|
2867
|
+
function compareReviewSequence(a, b) {
|
|
2868
|
+
const sequenceDelta = Number(a.sequence ?? 0) - Number(b.sequence ?? 0);
|
|
2869
|
+
if (sequenceDelta !== 0) return sequenceDelta;
|
|
2870
|
+
return String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
function compareReviewRecency(a, b) {
|
|
2874
|
+
const createdDelta = String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
|
|
2875
|
+
if (createdDelta !== 0 && a.createdAt && b.createdAt) return createdDelta;
|
|
2876
|
+
const aCheckpoint = Number(String(a.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
|
|
2877
|
+
const bCheckpoint = Number(String(b.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
|
|
2878
|
+
if (aCheckpoint !== bCheckpoint) return aCheckpoint - bCheckpoint;
|
|
2879
|
+
return compareReviewSequence(a, b);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
export function selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews) {
|
|
2883
|
+
const allReviews = acceptanceReviews?.reviews ?? [];
|
|
2884
|
+
const planned = Array.isArray(devPlan?.checkpoints) ? devPlan.checkpoints : [];
|
|
2885
|
+
if (planned.length <= 1) {
|
|
2886
|
+
return [...allReviews].sort(compareReviewRecency).slice(-1);
|
|
2887
|
+
}
|
|
2888
|
+
const selected = [];
|
|
2889
|
+
for (const checkpoint of planned) {
|
|
2890
|
+
const milestoneId = checkpoint.id;
|
|
2891
|
+
const candidates = allReviews.filter((review) =>
|
|
2892
|
+
review.milestoneId === milestoneId || review.checkpointId === milestoneId
|
|
2893
|
+
);
|
|
2894
|
+
if (candidates.length === 0) continue;
|
|
2895
|
+
selected.push([...candidates].sort(compareReviewSequence).at(-1));
|
|
2896
|
+
}
|
|
2897
|
+
return selected;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, context = {}) {
|
|
2692
2901
|
const reasons = [];
|
|
2693
2902
|
const nextActions = [];
|
|
2694
2903
|
const requiredCheckpoints = Array.isArray(devPlan?.checkpoints) ? devPlan.checkpoints.length : 0;
|
|
2695
2904
|
const checkpointCount = checkpoints?.count ?? 0;
|
|
2696
|
-
const
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2905
|
+
const allReviews = acceptanceReviews?.reviews ?? [];
|
|
2906
|
+
// Checkpoints produced after the planned set are revision/progress snapshots,
|
|
2907
|
+
// not additional required milestones. Judge the latest complete set so a
|
|
2908
|
+
// resolved historical blocker does not permanently poison the task.
|
|
2909
|
+
const effectiveReviews = selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews);
|
|
2910
|
+
const reviewCount = effectiveReviews.length;
|
|
2911
|
+
const acceptedCount = effectiveReviews.filter((item) => item.status === 'accepted').length;
|
|
2912
|
+
const reviseCount = effectiveReviews.filter((item) => item.status === 'revise').length;
|
|
2913
|
+
const blockedCount = effectiveReviews.filter((item) => item.status === 'blocked').length;
|
|
2700
2914
|
const dispatchStatus = context.dispatchStatus ?? 'unknown';
|
|
2915
|
+
const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
|
|
2701
2916
|
let outcome = 'needs_revision';
|
|
2702
2917
|
|
|
2703
2918
|
if (dispatchStatus === 'blocked_preflight') {
|
|
@@ -2740,6 +2955,10 @@ function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acc
|
|
|
2740
2955
|
outcome = 'needs_revision';
|
|
2741
2956
|
reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
|
|
2742
2957
|
nextActions.push('Complete and review the remaining planned checkpoints.');
|
|
2958
|
+
} else if (contract.task_scope === 'project' && !projectCompletionAccepted) {
|
|
2959
|
+
outcome = 'project_in_progress';
|
|
2960
|
+
reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
|
|
2961
|
+
nextActions.push('Continue with the next safe actionable project backlog item.');
|
|
2743
2962
|
} else if (contract.requires_human_gate) {
|
|
2744
2963
|
outcome = 'ready_for_human_review';
|
|
2745
2964
|
reasons.push('All reviewed checkpoints are accepted, and the task contract requires a human gate.');
|
|
@@ -2771,6 +2990,8 @@ function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acc
|
|
|
2771
2990
|
planned_checkpoints: requiredCheckpoints,
|
|
2772
2991
|
produced_checkpoints: checkpointCount,
|
|
2773
2992
|
reviews: reviewCount,
|
|
2993
|
+
historical_reviews: allReviews.length,
|
|
2994
|
+
effective_review_ids: effectiveReviews.map((review) => review.checkpointId),
|
|
2774
2995
|
accepted: acceptedCount,
|
|
2775
2996
|
revise: reviseCount,
|
|
2776
2997
|
blocked: blockedCount,
|
|
@@ -2809,6 +3030,7 @@ function queueStatusFromFinalJudgement(currentStatus, finalJudgement) {
|
|
|
2809
3030
|
if (currentStatus !== 'completed') return currentStatus;
|
|
2810
3031
|
const outcome = finalJudgement.judgement.outcome;
|
|
2811
3032
|
if (outcome === 'ready_to_apply') return currentStatus;
|
|
3033
|
+
if (outcome === 'project_in_progress') return outcome;
|
|
2812
3034
|
if (outcome === 'ready_for_human_review') return outcome;
|
|
2813
3035
|
return outcome;
|
|
2814
3036
|
}
|
|
@@ -2945,6 +3167,7 @@ export async function queueStatus(root, queue) {
|
|
|
2945
3167
|
queue,
|
|
2946
3168
|
queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
|
|
2947
3169
|
active: activeFiles.length,
|
|
3170
|
+
waiting: (await listJson(queueSubdirFor(root, queue, 'waiting'))).length,
|
|
2948
3171
|
done: (await listJson(queueSubdirFor(root, queue, 'done'))).length,
|
|
2949
3172
|
failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
|
|
2950
3173
|
canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
|
|
@@ -3561,7 +3784,7 @@ async function nextQueuedTaskFile(root, queue) {
|
|
|
3561
3784
|
return path.join(queueSubdirFor(root, queue, 'inbox'), files[0]);
|
|
3562
3785
|
}
|
|
3563
3786
|
|
|
3564
|
-
async function findTaskFile(root, queue, taskId, subdirs = ['inbox', 'active', 'failed', 'done', 'canceled']) {
|
|
3787
|
+
async function findTaskFile(root, queue, taskId, subdirs = ['inbox', 'active', 'waiting', 'failed', 'done', 'canceled']) {
|
|
3565
3788
|
const wanted = taskId.endsWith('.json') ? taskId : `${taskId}.json`;
|
|
3566
3789
|
for (const subdir of subdirs) {
|
|
3567
3790
|
const file = path.join(queueSubdirFor(root, queue, subdir), wanted);
|
|
@@ -4528,7 +4751,7 @@ export async function queueRevisionReview(root, queue, options = {}) {
|
|
|
4528
4751
|
};
|
|
4529
4752
|
}
|
|
4530
4753
|
|
|
4531
|
-
const TASK_STATE_DIRS = ['inbox', 'active', 'failed', 'done', 'canceled'];
|
|
4754
|
+
const TASK_STATE_DIRS = ['inbox', 'active', 'waiting', 'failed', 'done', 'canceled'];
|
|
4532
4755
|
|
|
4533
4756
|
async function listQueueTasks(root, queue) {
|
|
4534
4757
|
const tasks = [];
|
|
@@ -7523,6 +7746,37 @@ async function recoverStaleActive(root, queue, staleActiveMs) {
|
|
|
7523
7746
|
return recovered;
|
|
7524
7747
|
}
|
|
7525
7748
|
|
|
7749
|
+
async function recoverOrphanActive(root, queue) {
|
|
7750
|
+
const activeDir = queueSubdirFor(root, queue, 'active');
|
|
7751
|
+
const inboxDir = queueSubdirFor(root, queue, 'inbox');
|
|
7752
|
+
const files = await listJson(activeDir);
|
|
7753
|
+
const recovered = [];
|
|
7754
|
+
for (const file of files) {
|
|
7755
|
+
const activeFile = path.join(activeDir, file);
|
|
7756
|
+
const task = await readJson(activeFile);
|
|
7757
|
+
const recoveredAt = new Date().toISOString();
|
|
7758
|
+
const inboxFile = path.join(inboxDir, file);
|
|
7759
|
+
// Move first so a crash cannot leave duplicate active/inbox copies.
|
|
7760
|
+
await rename(activeFile, inboxFile);
|
|
7761
|
+
await writeJson(inboxFile, {
|
|
7762
|
+
...task,
|
|
7763
|
+
status: 'queued',
|
|
7764
|
+
orphanRecoveredAt: recoveredAt,
|
|
7765
|
+
orphanRecoveryCount: (task.orphanRecoveryCount ?? 0) + 1,
|
|
7766
|
+
requeuedAt: recoveredAt,
|
|
7767
|
+
requeuedFrom: 'active',
|
|
7768
|
+
recoveryReason: 'queue_lock_reacquired_with_active_task'
|
|
7769
|
+
});
|
|
7770
|
+
recovered.push({
|
|
7771
|
+
taskId: task.id,
|
|
7772
|
+
from: path.relative(root, activeFile),
|
|
7773
|
+
file: path.relative(root, inboxFile),
|
|
7774
|
+
recoveredAt
|
|
7775
|
+
});
|
|
7776
|
+
}
|
|
7777
|
+
return recovered;
|
|
7778
|
+
}
|
|
7779
|
+
|
|
7526
7780
|
function compactCommandResult(result) {
|
|
7527
7781
|
return {
|
|
7528
7782
|
exitCode: result.exitCode,
|
|
@@ -7885,6 +8139,18 @@ export async function runQueueOnce(root, options) {
|
|
|
7885
8139
|
}
|
|
7886
8140
|
|
|
7887
8141
|
try {
|
|
8142
|
+
// Holding this newly acquired lock proves no previous runner owns a valid
|
|
8143
|
+
// queue lease. Any task left in active/ is therefore an orphan from an
|
|
8144
|
+
// interrupted parent runner. Requeue it immediately so existing
|
|
8145
|
+
// checkpoints can be reviewed and execution can resume in this tick.
|
|
8146
|
+
const orphanRecovered = await recoverOrphanActive(root, queue);
|
|
8147
|
+
if (orphanRecovered.length > 0) {
|
|
8148
|
+
progress.emit('queue', 'orphan_recovered', `Recovered ${orphanRecovered.length} orphan active task(s)`, {
|
|
8149
|
+
queue,
|
|
8150
|
+
count: orphanRecovered.length,
|
|
8151
|
+
tasks: orphanRecovered.map((entry) => entry.taskId)
|
|
8152
|
+
});
|
|
8153
|
+
}
|
|
7888
8154
|
const staleRecovered = await recoverStaleActive(root, queue, options.staleActiveMs);
|
|
7889
8155
|
if (staleRecovered.length > 0) {
|
|
7890
8156
|
progress.emit('queue', 'recovered', `Recovered ${staleRecovered.length} stale active task(s)`, {
|
|
@@ -7903,6 +8169,7 @@ export async function runQueueOnce(root, options) {
|
|
|
7903
8169
|
status: 'empty',
|
|
7904
8170
|
exitCode: 0,
|
|
7905
8171
|
staleRecovered,
|
|
8172
|
+
orphanRecovered,
|
|
7906
8173
|
progress: progress.events
|
|
7907
8174
|
};
|
|
7908
8175
|
}
|
|
@@ -7993,8 +8260,13 @@ export async function runQueueOnce(root, options) {
|
|
|
7993
8260
|
finalStatus = 'blocked_preflight';
|
|
7994
8261
|
exitCode = 2;
|
|
7995
8262
|
} else {
|
|
8263
|
+
const humanInputContextFile = await prepareHumanInputContext(root, queue, task.id);
|
|
7996
8264
|
const runContext = {
|
|
7997
|
-
env:
|
|
8265
|
+
env: {
|
|
8266
|
+
...taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
|
|
8267
|
+
LOOP_HUMAN_INPUT_CONTEXT_FILE: humanInputContextFile,
|
|
8268
|
+
LOOP_HUMAN_INPUT_CONTEXT_FILE_REL: path.relative(root, humanInputContextFile)
|
|
8269
|
+
},
|
|
7998
8270
|
progress
|
|
7999
8271
|
};
|
|
8000
8272
|
if (finalStatus === 'unknown' && worktreeEnabled(options)) {
|
|
@@ -8031,6 +8303,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8031
8303
|
if (finalStatus === 'unknown') {
|
|
8032
8304
|
dispatchAttempts = await runDispatchWithRetry(root, options, queue, task, activeFile, runId, timeoutMs, runContext);
|
|
8033
8305
|
dispatch = dispatchAttempts[dispatchAttempts.length - 1]?.result ?? null;
|
|
8306
|
+
await destroyConsumedHumanInputSecrets(root, queue, task.id);
|
|
8034
8307
|
const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
|
|
8035
8308
|
if (dispatch?.canceled) {
|
|
8036
8309
|
finalStatus = 'superseded';
|
|
@@ -8053,6 +8326,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8053
8326
|
}
|
|
8054
8327
|
}
|
|
8055
8328
|
checkpoints = await checkpointSummary(root, devPlan);
|
|
8329
|
+
await reconcileHumanInputGates(root, queue, task.id);
|
|
8056
8330
|
progress.emit('acceptance', 'checkpoint_summary', `Collected ${checkpoints.count} checkpoint(s)`, {
|
|
8057
8331
|
taskId: task.id,
|
|
8058
8332
|
count: checkpoints.count,
|
|
@@ -8131,26 +8405,32 @@ export async function runQueueOnce(root, options) {
|
|
|
8131
8405
|
revisionRequest = await writeRevisionRequest(root, queue, task, taskContract, acceptancePlan, devPlan, finalJudgement, acceptanceReviews);
|
|
8132
8406
|
}
|
|
8133
8407
|
|
|
8134
|
-
destination = finalStatus === '
|
|
8408
|
+
destination = finalStatus === 'project_in_progress'
|
|
8409
|
+
? queueSubdirFor(root, queue, 'inbox')
|
|
8410
|
+
: finalStatus === 'completed'
|
|
8135
8411
|
? queueSubdirFor(root, queue, 'done')
|
|
8136
8412
|
: finalStatus === 'superseded' ? queueSubdirFor(root, queue, 'canceled')
|
|
8137
8413
|
: queueSubdirFor(root, queue, 'failed');
|
|
8138
|
-
exitCode = ['completed', 'superseded'].includes(finalStatus) ? 0 : 1;
|
|
8414
|
+
exitCode = ['completed', 'superseded', 'project_in_progress'].includes(finalStatus) ? 0 : 1;
|
|
8139
8415
|
|
|
8140
8416
|
const finishedAt = new Date().toISOString();
|
|
8141
|
-
progress.emit('queue', finalStatus === 'completed' ? 'completed' : 'needs_attention', `Task finished with status ${finalStatus}`, {
|
|
8417
|
+
progress.emit('queue', finalStatus === 'completed' ? 'completed' : finalStatus === 'project_in_progress' ? 'continued' : 'needs_attention', `Task finished with status ${finalStatus}`, {
|
|
8142
8418
|
queue,
|
|
8143
8419
|
taskId: task.id,
|
|
8144
8420
|
status: finalStatus
|
|
8145
8421
|
});
|
|
8146
8422
|
const completedTask = {
|
|
8147
8423
|
...task,
|
|
8148
|
-
status: finalStatus,
|
|
8424
|
+
status: finalStatus === 'project_in_progress' ? 'queued' : finalStatus,
|
|
8149
8425
|
startedAt,
|
|
8150
8426
|
finishedAt,
|
|
8151
8427
|
attempts: (task.attempts ?? 0) + Math.max(dispatchAttempts.length, dispatch ? 1 : 0),
|
|
8152
8428
|
runPath: path.relative(root, runPath)
|
|
8153
8429
|
};
|
|
8430
|
+
if (finalStatus === 'project_in_progress') {
|
|
8431
|
+
completedTask.projectContinuedAt = finishedAt;
|
|
8432
|
+
completedTask.projectContinuationCount = (task.projectContinuationCount ?? 0) + 1;
|
|
8433
|
+
}
|
|
8154
8434
|
const completedFile = path.join(destination, path.basename(activeFile));
|
|
8155
8435
|
await writeJson(completedFile, completedTask);
|
|
8156
8436
|
await rm(activeFile, { force: true });
|
|
@@ -8224,6 +8504,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8224
8504
|
} : null,
|
|
8225
8505
|
verification,
|
|
8226
8506
|
staleRecovered,
|
|
8507
|
+
orphanRecovered,
|
|
8227
8508
|
progress: progress.events,
|
|
8228
8509
|
taskPath: path.relative(root, completedFile),
|
|
8229
8510
|
runPath: path.relative(root, runPath)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
|
|
18
18
|
"check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
|
|
19
|
-
"check": "npm run check:config-drift && npm run check:openclaw-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node scripts/route-notify-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --
|
|
19
|
+
"check": "npm run check:config-drift && npm run check:openclaw-install && node --check bin/loop-engineering.mjs && node --check lib/core.mjs && node scripts/final-judgement-self-test.mjs && node scripts/route-notify-self-test.mjs && node scripts/scheduler-heartbeat-self-test.mjs && node bin/loop-engineering.mjs verify --config templates/workspace-health.json --root . && node bin/loop-engineering.mjs queue-status --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs project-intake --root /tmp/loop-engineering-check --name smoke-project --brief \"Build a small website project\" --type auto --check \"npm test\" --json >/dev/null && node bin/loop-engineering.mjs project-plan --root /tmp/loop-engineering-check --project smoke-project --force --json >/dev/null && node bin/loop-engineering.mjs project-status --root /tmp/loop-engineering-check --project smoke-project --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke --root /tmp/loop-engineering-check --plan-only --force-due --json >/dev/null && node bin/loop-engineering.mjs queue-scheduler-tick --queue smoke-progress --root /tmp/loop-engineering-check --plan-only --force-due --progress-report --progress-report-when-not-due --json >/dev/null && node bin/loop-engineering.mjs workflow-metrics --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs workflow-tune-plan --queue smoke --root . --json >/dev/null && node bin/loop-engineering.mjs code-queue-init --queue smoke-code --root /tmp/loop-engineering-check --force >/dev/null && node bin/loop-engineering.mjs code-worktree-list --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-status --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-task-dashboard --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup-plan --queue smoke-code --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs code-worktree-cleanup --queue smoke-code --root /tmp/loop-engineering-check --confirm-cleanup --json >/dev/null && node bin/loop-engineering.mjs code-patch-verify --patch README.md --root . --json >/dev/null && node bin/loop-engineering.mjs code-patch-apply-plan --patch README.md --json >/dev/null && node bin/loop-engineering.mjs queue-revision-ci-self-test --queue smoke-ci --root /tmp/loop-engineering-check --json >/dev/null && node bin/loop-engineering.mjs summarize --root . --json >/dev/null && node bin/loop-engineering.mjs doctor --root . --json >/dev/null",
|
|
20
20
|
"pack:dry": "npm pack --dry-run"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { buildFinalJudgement, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
|
|
3
|
+
|
|
4
|
+
const basePlan = { rubric: [], automation: [] };
|
|
5
|
+
const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: false, task_scope: 'scoped_task' };
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
9
|
+
const reviews = {
|
|
10
|
+
reviews: [
|
|
11
|
+
{ checkpointId: 'cp1', sequence: 1, status: 'blocked' },
|
|
12
|
+
{ checkpointId: 'cp2', sequence: 2, status: 'accepted' },
|
|
13
|
+
{ checkpointId: 'cp10', sequence: 10, status: 'accepted' }
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
assert.equal(selectEffectiveAcceptanceReviews(devPlan, reviews)[0].checkpointId, 'cp10');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
21
|
+
const reviews = {
|
|
22
|
+
reviews: [
|
|
23
|
+
{ checkpointId: 'cp5', milestoneId: 'G-01-retry', sequence: 5, createdAt: '2026-08-07T04:00:00.000Z', status: 'accepted' },
|
|
24
|
+
{ checkpointId: 'cp14', milestoneId: 'G-01-auth', sequence: 3, createdAt: '2026-08-07T12:00:00.000Z', status: 'blocked' }
|
|
25
|
+
]
|
|
26
|
+
};
|
|
27
|
+
assert.equal(selectEffectiveAcceptanceReviews(devPlan, reviews)[0].checkpointId, 'cp14');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
{
|
|
31
|
+
const devPlan = { checkpoints: [{ id: 'design' }, { id: 'verify' }] };
|
|
32
|
+
const reviews = {
|
|
33
|
+
reviews: [
|
|
34
|
+
{ checkpointId: 'design', milestoneId: 'design', sequence: 1, status: 'accepted' },
|
|
35
|
+
{ checkpointId: 'verify-v1', milestoneId: 'verify', sequence: 2, status: 'blocked' },
|
|
36
|
+
{ checkpointId: 'verify-v2', milestoneId: 'verify', sequence: 3, status: 'accepted' }
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
assert.deepEqual(selectEffectiveAcceptanceReviews(devPlan, reviews).map((review) => review.checkpointId), ['design', 'verify-v2']);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
44
|
+
const reviews = { reviews: [{ checkpointId: 'cp5', sequence: 5, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
|
|
45
|
+
const judgement = buildFinalJudgement(
|
|
46
|
+
{ ...baseContract, task_scope: 'project' },
|
|
47
|
+
basePlan,
|
|
48
|
+
devPlan,
|
|
49
|
+
{ count: 5 },
|
|
50
|
+
reviews,
|
|
51
|
+
{ dispatchStatus: 'completed' }
|
|
52
|
+
);
|
|
53
|
+
assert.equal(judgement.outcome, 'project_in_progress');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
58
|
+
const reviews = { reviews: [{ checkpointId: 'cp6', sequence: 6, status: 'accepted', projectCompletion: { status: 'accepted' } }] };
|
|
59
|
+
const judgement = buildFinalJudgement(
|
|
60
|
+
{ ...baseContract, task_scope: 'project' },
|
|
61
|
+
basePlan,
|
|
62
|
+
devPlan,
|
|
63
|
+
{ count: 6 },
|
|
64
|
+
reviews,
|
|
65
|
+
{ dispatchStatus: 'completed' }
|
|
66
|
+
);
|
|
67
|
+
assert.equal(judgement.outcome, 'ready_to_apply');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log('final judgement self-test passed');
|
|
@@ -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,38 @@ 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
58
|
if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE')) throw new Error('worker, recursion guard, or amendment polling missing');
|
|
48
59
|
const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
49
60
|
if (!instructions.includes('走 loop') || !instructions.includes('immediately execute')) throw new Error('conversation instructions missing');
|
|
50
61
|
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');
|
|
62
|
+
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');
|
|
63
|
+
const serviceFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.service');
|
|
64
|
+
const timerFile = path.join(process.env.XDG_CONFIG_HOME, 'systemd/user/openclaw-loop-test-tasks-scheduler.timer');
|
|
65
|
+
const service = await readFile(serviceFile, 'utf8');
|
|
66
|
+
const timer = await readFile(timerFile, 'utf8');
|
|
67
|
+
if (!service.includes('scheduler-tick') || !timer.includes('OnUnitActiveSec=1min')) throw new Error('scheduler systemd units were not installed');
|
|
68
|
+
const installSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
69
|
+
if (!installSystemctlCalls.includes('["--user","enable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not enabled');
|
|
70
|
+
const schedulerTick = await new Promise((resolve) => {
|
|
71
|
+
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'] });
|
|
72
|
+
let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
73
|
+
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
74
|
+
});
|
|
75
|
+
if (schedulerTick.code !== 0) throw new Error(`installed scheduler tick failed: ${schedulerTick.stderr || schedulerTick.stdout}`);
|
|
76
|
+
const schedulerState = JSON.parse(await readFile(path.join(root, 'runtime/loops/test-tasks/scheduler/state.json'), 'utf8'));
|
|
77
|
+
if (!schedulerState.generatedAt || !schedulerState.nextRunAt) throw new Error('installed scheduler tick did not persist its heartbeat and cadence');
|
|
52
78
|
const notifier = await readFile(path.join(root, 'scripts/loops/openclaw-loop-notify.mjs'), 'utf8');
|
|
53
79
|
if (!notifier.includes("'message', 'send'") || !notifier.includes('source.channel') || !notifier.includes('source.target')) throw new Error('channel-neutral notifier missing');
|
|
54
80
|
const delivery = await new Promise((resolve) => {
|
|
@@ -100,7 +126,7 @@ for (const generated of ['scripts/loops/openclaw-loop-dispatch.mjs', 'scripts/lo
|
|
|
100
126
|
});
|
|
101
127
|
if (check.code !== 0) throw new Error(`generated script syntax failed: ${generated}: ${check.stderr}`);
|
|
102
128
|
}
|
|
103
|
-
const conflict = await run([
|
|
129
|
+
const conflict = await run([...installBase, '--worker-agent', 'builder', '--confirm-install', '--json']);
|
|
104
130
|
if (conflict.code === 0) throw new Error('installer overwrote existing files without --force');
|
|
105
131
|
const manager = new URL('./openclaw-manage.mjs', import.meta.url).pathname;
|
|
106
132
|
async function manage(args) {
|
|
@@ -126,4 +152,7 @@ const uninstall = await manage(['--action', 'uninstall', '--confirm-uninstall'])
|
|
|
126
152
|
if (uninstall.code !== 0 || JSON.parse(uninstall.stdout).status !== 'uninstalled') throw new Error(`uninstall failed: ${uninstall.stderr}`);
|
|
127
153
|
if (!await readFile(path.join(root, 'runtime/loops/test-tasks/state.json'), 'utf8').catch(() => 'retained')) throw new Error('unexpected runtime cleanup result');
|
|
128
154
|
if (await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8').then(() => true).catch(() => false)) throw new Error('managed wrapper survived uninstall');
|
|
155
|
+
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');
|
|
156
|
+
const finalSystemctlCalls = await readFile(systemctlCapture, 'utf8');
|
|
157
|
+
if (!finalSystemctlCalls.includes('["--user","disable","--now","openclaw-loop-test-tasks-scheduler.timer"]')) throw new Error('scheduler timer was not disabled during uninstall');
|
|
129
158
|
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;
|
|
@@ -86,13 +91,13 @@ child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 :
|
|
|
86
91
|
`;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
function wrapperSource({ queue }) {
|
|
94
|
+
function wrapperSource({ queue, loopBin }) {
|
|
90
95
|
return `#!/usr/bin/env node
|
|
91
96
|
import { spawn } from 'node:child_process';
|
|
92
97
|
const [command, ...rest] = process.argv.slice(2);
|
|
93
98
|
function run(args) {
|
|
94
99
|
return new Promise((resolve) => {
|
|
95
|
-
const child = spawn(
|
|
100
|
+
const child = spawn(process.execPath, [${JSON.stringify(loopBin)}, ...args], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
|
|
96
101
|
child.on('close', (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
97
102
|
});
|
|
98
103
|
}
|
|
@@ -121,6 +126,11 @@ if (command === 'route') {
|
|
|
121
126
|
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
122
127
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
123
128
|
process.exitCode = runCode || humanNotifyCode || terminalNotifyCode;
|
|
129
|
+
} else if (command === 'scheduler-tick') {
|
|
130
|
+
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]);
|
|
131
|
+
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
132
|
+
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
133
|
+
process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;
|
|
124
134
|
} else {
|
|
125
135
|
console.error('Usage: node scripts/loops/openclaw-loop.mjs route --message "走 loop:任务" [source metadata]');
|
|
126
136
|
process.exitCode = 1;
|
|
@@ -128,6 +138,14 @@ if (command === 'route') {
|
|
|
128
138
|
`;
|
|
129
139
|
}
|
|
130
140
|
|
|
141
|
+
function schedulerServiceSource({ root, queue }) {
|
|
142
|
+
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`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function schedulerTimerSource({ queue }) {
|
|
146
|
+
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`;
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
function notifierSource({ openclawBin }) {
|
|
132
150
|
return `#!/usr/bin/env node
|
|
133
151
|
import { spawn } from 'node:child_process';
|
|
@@ -164,13 +182,17 @@ function instructionsBlock({ queue }) {
|
|
|
164
182
|
async function main() {
|
|
165
183
|
const args = parseArgs(process.argv.slice(2));
|
|
166
184
|
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]');
|
|
185
|
+
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
186
|
return;
|
|
169
187
|
}
|
|
170
188
|
safeId(args.queue, 'queue');
|
|
171
189
|
if (args.workerAgent) safeId(args.workerAgent, 'worker agent');
|
|
172
190
|
const worker = await resolveWorkerAgent(args);
|
|
173
191
|
args.workerAgent = worker.workerAgent;
|
|
192
|
+
args.loopBin = new URL('../bin/loop-engineering.mjs', import.meta.url).pathname;
|
|
193
|
+
const systemdUserDir = path.join(process.env.XDG_CONFIG_HOME || path.join(process.env.HOME || '', '.config'), 'systemd', 'user');
|
|
194
|
+
const schedulerUnit = `openclaw-loop-${args.queue}-scheduler.service`;
|
|
195
|
+
const schedulerTimer = `openclaw-loop-${args.queue}-scheduler.timer`;
|
|
174
196
|
const files = {
|
|
175
197
|
workspaceHealth: path.join(args.root, 'configs', 'loops', 'workspace-health.json'),
|
|
176
198
|
queueConfig: path.join(args.root, 'configs', 'loops', 'queues', `${args.queue}.json`),
|
|
@@ -178,11 +200,13 @@ async function main() {
|
|
|
178
200
|
wrapper: path.join(args.root, 'scripts', 'loops', 'openclaw-loop.mjs'),
|
|
179
201
|
notifier: path.join(args.root, 'scripts', 'loops', 'openclaw-loop-notify.mjs'),
|
|
180
202
|
manifest: path.join(args.root, 'runtime', 'loop-engineering-openclaw-install.json'),
|
|
181
|
-
instructions: path.join(args.root, 'AGENTS.md')
|
|
203
|
+
instructions: path.join(args.root, 'AGENTS.md'),
|
|
204
|
+
schedulerService: path.join(systemdUserDir, schedulerUnit),
|
|
205
|
+
schedulerTimer: path.join(systemdUserDir, schedulerTimer)
|
|
182
206
|
};
|
|
183
207
|
const conflicts = [];
|
|
184
208
|
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 };
|
|
209
|
+
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
210
|
report.next = args.confirmInstall ? 'Run loop-engineering-openclaw-doctor, then route a harmless smoke task.' : 'Review this plan, then rerun with --confirm-install.';
|
|
187
211
|
if (conflicts.length && !args.force && args.confirmInstall) throw new Error(`Refusing to overwrite: ${conflicts.join(', ')}. Use --force after review.`);
|
|
188
212
|
if (args.confirmInstall) {
|
|
@@ -203,28 +227,42 @@ async function main() {
|
|
|
203
227
|
dispatcher: 'node scripts/loops/openclaw-loop-dispatch.mjs',
|
|
204
228
|
preflightConfig: 'configs/loops/workspace-health.json',
|
|
205
229
|
timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000,
|
|
230
|
+
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' },
|
|
206
231
|
retry: { maxAttempts: 1, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] },
|
|
207
232
|
revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true }
|
|
208
233
|
}, null, 2)}\n`;
|
|
209
234
|
const dispatcherContent = dispatcherSource(args);
|
|
210
235
|
const wrapperContent = wrapperSource(args);
|
|
211
236
|
const notifierContent = notifierSource(args);
|
|
237
|
+
const schedulerServiceContent = schedulerServiceSource(args);
|
|
238
|
+
const schedulerTimerContent = schedulerTimerSource(args);
|
|
212
239
|
await writeFile(files.queueConfig, queueContent);
|
|
213
240
|
await writeFile(files.dispatcher, dispatcherContent);
|
|
214
241
|
await writeFile(files.wrapper, wrapperContent);
|
|
215
242
|
await writeFile(files.notifier, notifierContent);
|
|
243
|
+
await mkdir(systemdUserDir, { recursive: true });
|
|
244
|
+
await writeFile(files.schedulerService, schedulerServiceContent);
|
|
245
|
+
await writeFile(files.schedulerTimer, schedulerTimerContent);
|
|
246
|
+
const daemonReload = await run(args.systemctlBin, ['--user', 'daemon-reload'], { cwd: args.root });
|
|
247
|
+
if (daemonReload.code !== 0) throw new Error(`Cannot reload user systemd units: ${(daemonReload.stderr || daemonReload.stdout).trim() || `exit ${daemonReload.code}`}`);
|
|
248
|
+
const enableTimer = await run(args.systemctlBin, ['--user', 'enable', '--now', schedulerTimer], { cwd: args.root });
|
|
249
|
+
if (enableTimer.code !== 0) throw new Error(`Cannot enable Loop scheduler timer ${schedulerTimer}: ${(enableTimer.stderr || enableTimer.stdout).trim() || `exit ${enableTimer.code}`}`);
|
|
216
250
|
const instructions = await exists(files.instructions) ? await readFile(files.instructions, 'utf8') : '';
|
|
217
251
|
const managedInstructions = instructionsBlock(args);
|
|
218
252
|
if (!instructions.includes('<!-- loop-engineering:openclaw:start -->')) await appendFile(files.instructions, managedInstructions);
|
|
219
253
|
await mkdir(path.dirname(files.manifest), { recursive: true });
|
|
220
254
|
await writeFile(files.manifest, `${JSON.stringify({
|
|
221
|
-
version:
|
|
255
|
+
version: 2, queue: args.queue, workerAgent: args.workerAgent, openclawBin: args.openclawBin, systemctlBin: args.systemctlBin, installedAt: new Date().toISOString(),
|
|
222
256
|
managedFiles: [
|
|
223
257
|
{ path: path.relative(args.root, files.queueConfig), sha256: sha256(queueContent) },
|
|
224
258
|
{ path: path.relative(args.root, files.dispatcher), sha256: sha256(dispatcherContent) },
|
|
225
259
|
{ path: path.relative(args.root, files.wrapper), sha256: sha256(wrapperContent) },
|
|
226
260
|
{ path: path.relative(args.root, files.notifier), sha256: sha256(notifierContent) }
|
|
227
261
|
],
|
|
262
|
+
managedUnits: [
|
|
263
|
+
{ path: files.schedulerService, unit: schedulerUnit, sha256: sha256(schedulerServiceContent) },
|
|
264
|
+
{ path: files.schedulerTimer, unit: schedulerTimer, sha256: sha256(schedulerTimerContent) }
|
|
265
|
+
],
|
|
228
266
|
managedInstructions: { path: 'AGENTS.md', sha256: sha256(managedInstructions), content: managedInstructions },
|
|
229
267
|
retainedOnUninstall: [`runtime/loops/${args.queue}`]
|
|
230
268
|
}, 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
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { mkdtemp, readdir, rename, rm } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import {
|
|
@@ -78,6 +78,48 @@ const contract = await readJson(path.join(taskRuntimeDirFor(root, queue, routed.
|
|
|
78
78
|
assert.equal(contract.risk_level, 'model_assessed');
|
|
79
79
|
assert.equal(contract.requires_human_gate, false);
|
|
80
80
|
|
|
81
|
+
const orphanQueue = 'orphan-recovery-smoke';
|
|
82
|
+
const orphanRouted = await routeLoopMessage(root, {
|
|
83
|
+
route: true,
|
|
84
|
+
confirmExecute: true,
|
|
85
|
+
queue: orphanQueue,
|
|
86
|
+
message: '走 loop 验证异常退出自动恢复',
|
|
87
|
+
sourceChannel: 'feishu',
|
|
88
|
+
sourceTarget: 'user-1'
|
|
89
|
+
});
|
|
90
|
+
const orphanName = `${orphanRouted.task.id}.json`;
|
|
91
|
+
const orphanInbox = path.join(queueSubdirFor(root, orphanQueue, 'inbox'), orphanName);
|
|
92
|
+
const orphanActive = path.join(queueSubdirFor(root, orphanQueue, 'active'), orphanName);
|
|
93
|
+
await writeJson(orphanActive, { ...(await readJson(orphanInbox)), status: 'active', startedAt: new Date().toISOString() });
|
|
94
|
+
await rm(orphanInbox, { force: true });
|
|
95
|
+
await writeJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'), {
|
|
96
|
+
version: 1,
|
|
97
|
+
task_id: orphanRouted.task.id,
|
|
98
|
+
checkpoint_id: 'cp-before-crash',
|
|
99
|
+
status: 'in_progress',
|
|
100
|
+
summary: 'Durable progress written before the parent runner exited.',
|
|
101
|
+
files_changed: [],
|
|
102
|
+
verification: [],
|
|
103
|
+
blockers: [],
|
|
104
|
+
risks: [],
|
|
105
|
+
next_action: 'resume_from_checkpoint'
|
|
106
|
+
});
|
|
107
|
+
const orphanRun = await runQueueOnce(root, {
|
|
108
|
+
queue: orphanQueue,
|
|
109
|
+
dispatcher: '/bin/true',
|
|
110
|
+
timeoutMs: 10_000,
|
|
111
|
+
leaseMs: 20_000,
|
|
112
|
+
staleActiveMs: 60_000
|
|
113
|
+
});
|
|
114
|
+
assert.equal(orphanRun.processed, true);
|
|
115
|
+
assert.equal(orphanRun.run.orphanRecovered.length, 1);
|
|
116
|
+
assert.equal(orphanRun.run.orphanRecovered[0].taskId, orphanRouted.task.id);
|
|
117
|
+
assert.ok(orphanRun.progress.some((event) => event.status === 'orphan_recovered'));
|
|
118
|
+
const orphanTerminal = await readJson(path.join(root, orphanRun.taskPath));
|
|
119
|
+
assert.equal(orphanTerminal.orphanRecoveryCount, 1);
|
|
120
|
+
assert.equal(orphanTerminal.requeuedFrom, 'active');
|
|
121
|
+
assert.equal((await readJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'))).checkpoint_id, 'cp-before-crash');
|
|
122
|
+
|
|
81
123
|
const reviewQueue = 'human-review-smoke';
|
|
82
124
|
const reviewTask = await routeLoopMessage(root, {
|
|
83
125
|
route: true,
|
|
@@ -332,10 +374,45 @@ const gateId = gateSent.results[0].gateId;
|
|
|
332
374
|
const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1' });
|
|
333
375
|
assert.equal(resolved.outcome, 'resolved_and_requeued');
|
|
334
376
|
const requeuedTask = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
|
|
335
|
-
assert.equal(requeuedTask.humanInput.
|
|
336
|
-
assert.
|
|
377
|
+
assert.equal(requeuedTask.humanInput.secret_received, true);
|
|
378
|
+
assert.equal(requeuedTask.body.includes('123456'), false);
|
|
379
|
+
const resolvedGate = await readJson(path.join(root, resolved.ledger ?? gateSent.results[0].ledger));
|
|
380
|
+
assert.equal(JSON.stringify(resolvedGate).includes('123456'), false);
|
|
381
|
+
assert.equal(resolvedGate.response_sha256.length, 64);
|
|
382
|
+
|
|
383
|
+
// Queued tasks receive a non-sensitive event reference without being moved.
|
|
384
|
+
const queuedCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-queued.json');
|
|
385
|
+
await writeJson(queuedCheckpoint, {
|
|
386
|
+
version: 1, task_id: routed.task.id, checkpoint_id: 'cp-queued', status: 'needs_human_input',
|
|
387
|
+
blockers: ['Provide queued input.'], verification: [], risks: [], next_action: 'wait'
|
|
388
|
+
});
|
|
389
|
+
await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
390
|
+
const queuedGateId = `${routed.task.id}:cp-queued`;
|
|
391
|
+
const waitingTaskFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(failedFile));
|
|
392
|
+
assert.equal((await readJson(waitingTaskFile)).status, 'waiting_for_human');
|
|
393
|
+
const queuedResolved = await resolveHumanInput(root, { queue, gateId: queuedGateId, input: 'queued-secret' });
|
|
394
|
+
assert.equal(queuedResolved.outcome, 'resolved_and_requeued');
|
|
395
|
+
const queuedAfterInput = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
|
|
396
|
+
assert.equal(queuedAfterInput.status, 'queued');
|
|
397
|
+
assert.equal(JSON.stringify(queuedAfterInput).includes('queued-secret'), false);
|
|
398
|
+
|
|
399
|
+
// Active tasks are not mutated; their event is consumed at the next safe tick.
|
|
400
|
+
const inboxRequeued = path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile));
|
|
401
|
+
const activeRequeued = path.join(queueSubdirFor(root, queue, 'active'), path.basename(failedFile));
|
|
402
|
+
await rename(inboxRequeued, activeRequeued);
|
|
403
|
+
const activeBefore = await readJson(activeRequeued);
|
|
404
|
+
const activeCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-active.json');
|
|
405
|
+
await writeJson(activeCheckpoint, {
|
|
406
|
+
version: 1, task_id: routed.task.id, checkpoint_id: 'cp-active', status: 'needs_human_input',
|
|
407
|
+
blockers: ['Provide active input.'], verification: [], risks: [], next_action: 'wait'
|
|
408
|
+
});
|
|
409
|
+
await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
410
|
+
const activeResolved = await resolveHumanInput(root, { queue, gateId: `${routed.task.id}:cp-active`, input: 'active-secret' });
|
|
411
|
+
assert.equal(activeResolved.outcome, 'resolved_pending_safe_boundary');
|
|
412
|
+
assert.deepEqual(await readJson(activeRequeued), activeBefore);
|
|
413
|
+
await rename(activeRequeued, inboxRequeued);
|
|
337
414
|
await writeJson(failedFile, { ...requeuedTask, status: 'needs_human_input' });
|
|
338
|
-
await rm(
|
|
415
|
+
await rm(inboxRequeued, { force: true });
|
|
339
416
|
|
|
340
417
|
const dryRun = await notifyTerminalTasks(root, { queue, dryRun: true });
|
|
341
418
|
assert.equal(dryRun.results[0].outcome, 'dry_run');
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
doctorReport,
|
|
8
|
+
enqueueTask,
|
|
9
|
+
queueSchedulerTick,
|
|
10
|
+
writeJson
|
|
11
|
+
} from '../lib/core.mjs';
|
|
12
|
+
|
|
13
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-scheduler-heartbeat-'));
|
|
14
|
+
const queue = 'required-scheduler';
|
|
15
|
+
await mkdir(path.join(root, 'configs', 'loops', 'queues'), { recursive: true });
|
|
16
|
+
await writeJson(path.join(root, 'configs', 'loops', 'queues', `${queue}.json`), {
|
|
17
|
+
queue,
|
|
18
|
+
dispatcher: '/bin/true',
|
|
19
|
+
scheduler: {
|
|
20
|
+
required: true,
|
|
21
|
+
heartbeatMaxAge: '5m',
|
|
22
|
+
initialInterval: '1m',
|
|
23
|
+
minInterval: '1m',
|
|
24
|
+
maxInterval: '4h'
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
await enqueueTask(root, { queue, title: 'heartbeat smoke', task: 'remain queued' });
|
|
28
|
+
|
|
29
|
+
const missing = await doctorReport(root);
|
|
30
|
+
const missingCheck = missing.checks.find((check) => check.id === `queue:${queue}:scheduler-heartbeat`);
|
|
31
|
+
assert.equal(missingCheck?.ok, false);
|
|
32
|
+
assert.equal(missingCheck?.detail?.code, 'scheduler_missing');
|
|
33
|
+
|
|
34
|
+
await queueSchedulerTick(root, {
|
|
35
|
+
queue,
|
|
36
|
+
scheduler: { initialInterval: '1m', minInterval: '1m', maxInterval: '4h' },
|
|
37
|
+
planOnly: true,
|
|
38
|
+
forceDue: true
|
|
39
|
+
});
|
|
40
|
+
const healthy = await doctorReport(root);
|
|
41
|
+
const healthyCheck = healthy.checks.find((check) => check.id === `queue:${queue}:scheduler-heartbeat`);
|
|
42
|
+
assert.equal(healthyCheck?.ok, true);
|
|
43
|
+
assert.equal(healthyCheck?.detail?.code, 'scheduler_healthy');
|
|
44
|
+
|
|
45
|
+
await rm(root, { recursive: true, force: true });
|
|
46
|
+
console.log('scheduler heartbeat self-test passed');
|
|
@@ -292,6 +292,8 @@ Summaries must cite the latest run/task/project evidence, verification performed
|
|
|
292
292
|
|
|
293
293
|
Use scheduler ticks only after manual verification. Adaptive schedules may speed up with successful queued work and back off on empty queues, failures, long runs, or human gates.
|
|
294
294
|
|
|
295
|
+
`queue-scheduler-tick` is adaptive cadence logic, not a resident daemon. A cron, systemd timer, or equivalent external scheduler must wake it regularly. For project queues that promise automatic continuation, set `scheduler.required=true` and a bounded `scheduler.heartbeatMaxAge`; `doctor` must fail with `scheduler_missing` whenever queued work exists without a fresh scheduler heartbeat.
|
|
296
|
+
|
|
295
297
|
Progress notification must be scoped and idempotent. Report failures, human gates, status changes, and terminal completion promptly; throttle routine progress and idle updates.
|
|
296
298
|
|
|
297
299
|
## Final Checklist
|