taskforce-loop-engineering 0.7.1 → 0.8.0
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 +17 -0
- package/lib/core.mjs +362 -38
- package/package.json +2 -2
- package/scripts/final-judgement-self-test.mjs +70 -0
- package/scripts/route-notify-self-test.mjs +132 -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,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
- Add project-aware completion semantics: accepted milestones return project tasks to `inbox/` as `project_in_progress` until an explicit project terminal contract is accepted.
|
|
6
|
+
- 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.
|
|
7
|
+
- Separate current blockers from `deferred_gates`, and preserve future authorization boundaries without blocking safe local backlog work.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
- Add regression coverage for project continuation, checkpoint lineage and ordering, human-input state transitions and redaction, orphan recovery, and scheduler heartbeat fail-closed behavior.
|
|
13
|
+
|
|
14
|
+
## 0.7.2 - 2026-08-06
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
- Forward configured human-gate policy into generated task contracts so queue state and final judgement agree.
|
|
18
|
+
- Add regression coverage for the complete `ready_for_human_review → approve → completed` lifecycle.
|
|
19
|
+
|
|
3
20
|
## 0.7.1 - 2026-08-05
|
|
4
21
|
|
|
5
22
|
- Make the standalone ClawHub skill self-sufficient by documenting the official npm package, GitHub repository, Node.js requirement, license, and installation commands.
|
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 {
|
|
@@ -1673,13 +1693,20 @@ export async function routeLoopMessage(root, options = {}) {
|
|
|
1673
1693
|
}
|
|
1674
1694
|
|
|
1675
1695
|
function terminalNotificationMessage(queue, task) {
|
|
1676
|
-
const
|
|
1696
|
+
const needsReview = task.status === 'ready_for_human_review';
|
|
1697
|
+
const needsHuman = ['needs_human_input', 'blocked', 'ready_for_human_review'].includes(task.status);
|
|
1677
1698
|
return [
|
|
1678
|
-
|
|
1699
|
+
needsReview ? 'Loop task is ready for human acceptance'
|
|
1700
|
+
: needsHuman ? 'Loop task needs human input'
|
|
1701
|
+
: 'Loop task reached a terminal state',
|
|
1679
1702
|
`task: ${task.title}`,
|
|
1680
1703
|
`queue: ${queue}`,
|
|
1681
1704
|
`status: ${task.status}`,
|
|
1682
|
-
...(
|
|
1705
|
+
...(needsReview
|
|
1706
|
+
? [`next: review the final judgement and record approve, request_changes, or reject for task ${task.id}.`]
|
|
1707
|
+
: needsHuman
|
|
1708
|
+
? ['next: inspect the task final judgement and checkpoints, resolve the blocker, then explicitly continue or requeue.']
|
|
1709
|
+
: [])
|
|
1683
1710
|
].join('\n');
|
|
1684
1711
|
}
|
|
1685
1712
|
|
|
@@ -1696,7 +1723,16 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
1696
1723
|
const dir = queueSubdirFor(root, queue, subdir);
|
|
1697
1724
|
for (const file of await listJson(dir)) {
|
|
1698
1725
|
const task = await readJson(path.join(dir, file));
|
|
1699
|
-
if (!task?.
|
|
1726
|
+
if (!task?.id || !task?.status) continue;
|
|
1727
|
+
if (!task?.source?.channel || !task?.source?.target) {
|
|
1728
|
+
results.push({
|
|
1729
|
+
taskId: task.id,
|
|
1730
|
+
status: task.status,
|
|
1731
|
+
outcome: 'failed',
|
|
1732
|
+
error: 'missing source.channel/source.target; refusing unscoped terminal delivery'
|
|
1733
|
+
});
|
|
1734
|
+
continue;
|
|
1735
|
+
}
|
|
1700
1736
|
const key = `${safeTaskId(task.id)}.${normalizeLoopId(task.status)}.json`;
|
|
1701
1737
|
const ledgerFile = path.join(notificationDir, key);
|
|
1702
1738
|
if (await exists(ledgerFile)) {
|
|
@@ -1764,7 +1800,7 @@ function humanInputMessage(queue, task, checkpoint, gateId) {
|
|
|
1764
1800
|
|
|
1765
1801
|
async function tasksById(root, queue) {
|
|
1766
1802
|
const tasks = new Map();
|
|
1767
|
-
for (const subdir of ['inbox', 'active', 'done', 'failed', 'canceled']) {
|
|
1803
|
+
for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
|
|
1768
1804
|
for (const file of await listJson(queueSubdirFor(root, queue, subdir))) {
|
|
1769
1805
|
const task = await readJson(path.join(queueSubdirFor(root, queue, subdir), file));
|
|
1770
1806
|
if (task?.id) tasks.set(task.id, { task, subdir });
|
|
@@ -1794,6 +1830,19 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1794
1830
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
1795
1831
|
if (await exists(ledgerFile)) {
|
|
1796
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
|
+
}
|
|
1797
1846
|
results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
1798
1847
|
continue;
|
|
1799
1848
|
}
|
|
@@ -1831,6 +1880,19 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1831
1880
|
requested_at: new Date().toISOString(),
|
|
1832
1881
|
notification: compactCommandResult(result)
|
|
1833
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
|
+
}
|
|
1834
1896
|
results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
|
|
1835
1897
|
}
|
|
1836
1898
|
}
|
|
@@ -1856,37 +1918,153 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1856
1918
|
const ledgerFile = path.join(queueDirFor(root, queue), 'human-input', 'gates', `${safeTaskId(taskId)}.${checkpointId}.json`);
|
|
1857
1919
|
if (!await exists(ledgerFile)) throw new Error(`Human-input gate not found: ${options.gateId}`);
|
|
1858
1920
|
const gate = await readJson(ledgerFile);
|
|
1859
|
-
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');
|
|
1860
1936
|
const resolved = {
|
|
1861
1937
|
...gate,
|
|
1862
1938
|
status: 'resolved',
|
|
1863
|
-
|
|
1939
|
+
secret_received: true,
|
|
1940
|
+
response_sha256: responseSha256,
|
|
1941
|
+
response_ref: path.relative(root, secretFile),
|
|
1864
1942
|
response_message_id: options.sourceMessageId ?? null,
|
|
1865
|
-
resolved_at:
|
|
1943
|
+
resolved_at: receivedAt
|
|
1866
1944
|
};
|
|
1945
|
+
delete resolved.response;
|
|
1867
1946
|
await writeJson(ledgerFile, resolved);
|
|
1868
|
-
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']);
|
|
1869
1962
|
let requeued = null;
|
|
1963
|
+
let updated = null;
|
|
1870
1964
|
if (found) {
|
|
1871
1965
|
const task = await readJson(found.file);
|
|
1872
|
-
const
|
|
1873
|
-
await writeJson(inboxFile, {
|
|
1966
|
+
const taskUpdate = {
|
|
1874
1967
|
...task,
|
|
1875
|
-
status: 'queued',
|
|
1876
|
-
body: `${task.body}\n\nHuman input for gate ${options.gateId}:\n${options.input.trim()}`,
|
|
1877
1968
|
humanInput: {
|
|
1878
1969
|
gate_id: options.gateId,
|
|
1879
1970
|
checkpoint_id: checkpointId,
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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
|
|
1885
2066
|
});
|
|
1886
|
-
await rm(found.file, { force: true });
|
|
1887
|
-
requeued = path.relative(root, inboxFile);
|
|
1888
2067
|
}
|
|
1889
|
-
return { gate: resolved, outcome: requeued ? 'resolved_and_requeued' : 'resolved_pending_terminal', requeued };
|
|
1890
2068
|
}
|
|
1891
2069
|
|
|
1892
2070
|
export function taskRuntimeDirFor(root, queue, taskId) {
|
|
@@ -2017,6 +2195,10 @@ function buildTaskContract(queue, task, options = {}) {
|
|
|
2017
2195
|
const inferredRisk = inferTaskRisk(task);
|
|
2018
2196
|
const modelAssessed = task.riskAssessment === 'model_assessed';
|
|
2019
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';
|
|
2020
2202
|
return {
|
|
2021
2203
|
version: 1,
|
|
2022
2204
|
task_id: task.id,
|
|
@@ -2024,6 +2206,7 @@ function buildTaskContract(queue, task, options = {}) {
|
|
|
2024
2206
|
title: task.title,
|
|
2025
2207
|
original_request: task.body,
|
|
2026
2208
|
goal: task.body,
|
|
2209
|
+
task_scope: taskScope,
|
|
2027
2210
|
deliverables: [
|
|
2028
2211
|
'Structured run artifact',
|
|
2029
2212
|
'Concise completion summary',
|
|
@@ -2362,11 +2545,15 @@ function buildDevPlan(contract, acceptancePlan) {
|
|
|
2362
2545
|
version: 1,
|
|
2363
2546
|
task_id: contract.task_id,
|
|
2364
2547
|
checkpoint_id: firstCheckpointId,
|
|
2548
|
+
milestone_id: firstCheckpointId,
|
|
2549
|
+
revises_checkpoint_id: null,
|
|
2365
2550
|
status: 'ready_for_acceptance | blocked | needs_human_input',
|
|
2366
2551
|
summary: 'What changed and why.',
|
|
2367
2552
|
files_changed: [],
|
|
2368
2553
|
verification: [],
|
|
2369
2554
|
blockers: [],
|
|
2555
|
+
deferred_gates: [],
|
|
2556
|
+
project_completion: null,
|
|
2370
2557
|
risks: [],
|
|
2371
2558
|
next_action: 'acceptance_review'
|
|
2372
2559
|
},
|
|
@@ -2655,6 +2842,11 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
2655
2842
|
await writeJson(reviewFile, review);
|
|
2656
2843
|
reviews.push({
|
|
2657
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,
|
|
2658
2850
|
status: review.status,
|
|
2659
2851
|
file: path.relative(root, reviewFile)
|
|
2660
2852
|
});
|
|
@@ -2672,16 +2864,55 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
2672
2864
|
};
|
|
2673
2865
|
}
|
|
2674
2866
|
|
|
2675
|
-
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 = {}) {
|
|
2676
2901
|
const reasons = [];
|
|
2677
2902
|
const nextActions = [];
|
|
2678
2903
|
const requiredCheckpoints = Array.isArray(devPlan?.checkpoints) ? devPlan.checkpoints.length : 0;
|
|
2679
2904
|
const checkpointCount = checkpoints?.count ?? 0;
|
|
2680
|
-
const
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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;
|
|
2684
2914
|
const dispatchStatus = context.dispatchStatus ?? 'unknown';
|
|
2915
|
+
const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
|
|
2685
2916
|
let outcome = 'needs_revision';
|
|
2686
2917
|
|
|
2687
2918
|
if (dispatchStatus === 'blocked_preflight') {
|
|
@@ -2724,6 +2955,10 @@ function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acc
|
|
|
2724
2955
|
outcome = 'needs_revision';
|
|
2725
2956
|
reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
|
|
2726
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.');
|
|
2727
2962
|
} else if (contract.requires_human_gate) {
|
|
2728
2963
|
outcome = 'ready_for_human_review';
|
|
2729
2964
|
reasons.push('All reviewed checkpoints are accepted, and the task contract requires a human gate.');
|
|
@@ -2755,6 +2990,8 @@ function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acc
|
|
|
2755
2990
|
planned_checkpoints: requiredCheckpoints,
|
|
2756
2991
|
produced_checkpoints: checkpointCount,
|
|
2757
2992
|
reviews: reviewCount,
|
|
2993
|
+
historical_reviews: allReviews.length,
|
|
2994
|
+
effective_review_ids: effectiveReviews.map((review) => review.checkpointId),
|
|
2758
2995
|
accepted: acceptedCount,
|
|
2759
2996
|
revise: reviseCount,
|
|
2760
2997
|
blocked: blockedCount,
|
|
@@ -2792,7 +3029,9 @@ function queueStatusFromFinalJudgement(currentStatus, finalJudgement) {
|
|
|
2792
3029
|
if (!finalJudgement?.judgement) return currentStatus;
|
|
2793
3030
|
if (currentStatus !== 'completed') return currentStatus;
|
|
2794
3031
|
const outcome = finalJudgement.judgement.outcome;
|
|
2795
|
-
if (outcome === '
|
|
3032
|
+
if (outcome === 'ready_to_apply') return currentStatus;
|
|
3033
|
+
if (outcome === 'project_in_progress') return outcome;
|
|
3034
|
+
if (outcome === 'ready_for_human_review') return outcome;
|
|
2796
3035
|
return outcome;
|
|
2797
3036
|
}
|
|
2798
3037
|
|
|
@@ -2928,6 +3167,7 @@ export async function queueStatus(root, queue) {
|
|
|
2928
3167
|
queue,
|
|
2929
3168
|
queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
|
|
2930
3169
|
active: activeFiles.length,
|
|
3170
|
+
waiting: (await listJson(queueSubdirFor(root, queue, 'waiting'))).length,
|
|
2931
3171
|
done: (await listJson(queueSubdirFor(root, queue, 'done'))).length,
|
|
2932
3172
|
failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
|
|
2933
3173
|
canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
|
|
@@ -3544,7 +3784,7 @@ async function nextQueuedTaskFile(root, queue) {
|
|
|
3544
3784
|
return path.join(queueSubdirFor(root, queue, 'inbox'), files[0]);
|
|
3545
3785
|
}
|
|
3546
3786
|
|
|
3547
|
-
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']) {
|
|
3548
3788
|
const wanted = taskId.endsWith('.json') ? taskId : `${taskId}.json`;
|
|
3549
3789
|
for (const subdir of subdirs) {
|
|
3550
3790
|
const file = path.join(queueSubdirFor(root, queue, subdir), wanted);
|
|
@@ -4069,6 +4309,27 @@ export async function queueHumanDecision(root, queue, taskId, options = {}) {
|
|
|
4069
4309
|
};
|
|
4070
4310
|
await writeJson(file, artifact);
|
|
4071
4311
|
|
|
4312
|
+
let transitionedTask = null;
|
|
4313
|
+
if (decision === 'approve' && found.subdir !== 'done') {
|
|
4314
|
+
const completedAt = artifact.created_at;
|
|
4315
|
+
transitionedTask = {
|
|
4316
|
+
...task,
|
|
4317
|
+
status: 'completed',
|
|
4318
|
+
humanApprovedAt: completedAt,
|
|
4319
|
+
humanReviewDecision: path.relative(root, file)
|
|
4320
|
+
};
|
|
4321
|
+
const completedFile = path.join(queueSubdirFor(root, normalized, 'done'), path.basename(found.file));
|
|
4322
|
+
await writeJson(completedFile, transitionedTask);
|
|
4323
|
+
await rm(found.file, { force: true });
|
|
4324
|
+
artifact.effects.queueTransition = {
|
|
4325
|
+
from: found.subdir,
|
|
4326
|
+
to: 'done',
|
|
4327
|
+
status: 'completed',
|
|
4328
|
+
file: path.relative(root, completedFile)
|
|
4329
|
+
};
|
|
4330
|
+
await writeJson(file, artifact);
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4072
4333
|
let revisionRequest = null;
|
|
4073
4334
|
if (decision === 'request_changes') {
|
|
4074
4335
|
const revisionFile = path.join(dir, 'human_revision_request.json');
|
|
@@ -4098,6 +4359,7 @@ export async function queueHumanDecision(root, queue, taskId, options = {}) {
|
|
|
4098
4359
|
decisionFile: path.relative(root, file),
|
|
4099
4360
|
revisionRequestFile: revisionRequest?.path ?? null,
|
|
4100
4361
|
revisionNext,
|
|
4362
|
+
transitionedTask,
|
|
4101
4363
|
artifact
|
|
4102
4364
|
};
|
|
4103
4365
|
}
|
|
@@ -4489,7 +4751,7 @@ export async function queueRevisionReview(root, queue, options = {}) {
|
|
|
4489
4751
|
};
|
|
4490
4752
|
}
|
|
4491
4753
|
|
|
4492
|
-
const TASK_STATE_DIRS = ['inbox', 'active', 'failed', 'done', 'canceled'];
|
|
4754
|
+
const TASK_STATE_DIRS = ['inbox', 'active', 'waiting', 'failed', 'done', 'canceled'];
|
|
4493
4755
|
|
|
4494
4756
|
async function listQueueTasks(root, queue) {
|
|
4495
4757
|
const tasks = [];
|
|
@@ -7484,6 +7746,37 @@ async function recoverStaleActive(root, queue, staleActiveMs) {
|
|
|
7484
7746
|
return recovered;
|
|
7485
7747
|
}
|
|
7486
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
|
+
|
|
7487
7780
|
function compactCommandResult(result) {
|
|
7488
7781
|
return {
|
|
7489
7782
|
exitCode: result.exitCode,
|
|
@@ -7846,6 +8139,18 @@ export async function runQueueOnce(root, options) {
|
|
|
7846
8139
|
}
|
|
7847
8140
|
|
|
7848
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
|
+
}
|
|
7849
8154
|
const staleRecovered = await recoverStaleActive(root, queue, options.staleActiveMs);
|
|
7850
8155
|
if (staleRecovered.length > 0) {
|
|
7851
8156
|
progress.emit('queue', 'recovered', `Recovered ${staleRecovered.length} stale active task(s)`, {
|
|
@@ -7864,6 +8169,7 @@ export async function runQueueOnce(root, options) {
|
|
|
7864
8169
|
status: 'empty',
|
|
7865
8170
|
exitCode: 0,
|
|
7866
8171
|
staleRecovered,
|
|
8172
|
+
orphanRecovered,
|
|
7867
8173
|
progress: progress.events
|
|
7868
8174
|
};
|
|
7869
8175
|
}
|
|
@@ -7901,7 +8207,11 @@ export async function runQueueOnce(root, options) {
|
|
|
7901
8207
|
let revisionRequest = null;
|
|
7902
8208
|
|
|
7903
8209
|
try {
|
|
7904
|
-
taskContract = await writeTaskContract(root, queue, task
|
|
8210
|
+
taskContract = await writeTaskContract(root, queue, task, {
|
|
8211
|
+
riskLevel: options.riskLevel,
|
|
8212
|
+
riskReasons: options.riskReasons,
|
|
8213
|
+
requiresHumanGate: options.requiresHumanGate
|
|
8214
|
+
});
|
|
7905
8215
|
progress.emit('planning', 'task_contract', `Wrote task contract (${taskContract.contract.risk_level})`, {
|
|
7906
8216
|
taskId: task.id,
|
|
7907
8217
|
artifact: taskContract.file,
|
|
@@ -7950,8 +8260,13 @@ export async function runQueueOnce(root, options) {
|
|
|
7950
8260
|
finalStatus = 'blocked_preflight';
|
|
7951
8261
|
exitCode = 2;
|
|
7952
8262
|
} else {
|
|
8263
|
+
const humanInputContextFile = await prepareHumanInputContext(root, queue, task.id);
|
|
7953
8264
|
const runContext = {
|
|
7954
|
-
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
|
+
},
|
|
7955
8270
|
progress
|
|
7956
8271
|
};
|
|
7957
8272
|
if (finalStatus === 'unknown' && worktreeEnabled(options)) {
|
|
@@ -7988,6 +8303,7 @@ export async function runQueueOnce(root, options) {
|
|
|
7988
8303
|
if (finalStatus === 'unknown') {
|
|
7989
8304
|
dispatchAttempts = await runDispatchWithRetry(root, options, queue, task, activeFile, runId, timeoutMs, runContext);
|
|
7990
8305
|
dispatch = dispatchAttempts[dispatchAttempts.length - 1]?.result ?? null;
|
|
8306
|
+
await destroyConsumedHumanInputSecrets(root, queue, task.id);
|
|
7991
8307
|
const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
|
|
7992
8308
|
if (dispatch?.canceled) {
|
|
7993
8309
|
finalStatus = 'superseded';
|
|
@@ -8010,6 +8326,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8010
8326
|
}
|
|
8011
8327
|
}
|
|
8012
8328
|
checkpoints = await checkpointSummary(root, devPlan);
|
|
8329
|
+
await reconcileHumanInputGates(root, queue, task.id);
|
|
8013
8330
|
progress.emit('acceptance', 'checkpoint_summary', `Collected ${checkpoints.count} checkpoint(s)`, {
|
|
8014
8331
|
taskId: task.id,
|
|
8015
8332
|
count: checkpoints.count,
|
|
@@ -8088,26 +8405,32 @@ export async function runQueueOnce(root, options) {
|
|
|
8088
8405
|
revisionRequest = await writeRevisionRequest(root, queue, task, taskContract, acceptancePlan, devPlan, finalJudgement, acceptanceReviews);
|
|
8089
8406
|
}
|
|
8090
8407
|
|
|
8091
|
-
destination = finalStatus === '
|
|
8408
|
+
destination = finalStatus === 'project_in_progress'
|
|
8409
|
+
? queueSubdirFor(root, queue, 'inbox')
|
|
8410
|
+
: finalStatus === 'completed'
|
|
8092
8411
|
? queueSubdirFor(root, queue, 'done')
|
|
8093
8412
|
: finalStatus === 'superseded' ? queueSubdirFor(root, queue, 'canceled')
|
|
8094
8413
|
: queueSubdirFor(root, queue, 'failed');
|
|
8095
|
-
exitCode = ['completed', 'superseded'].includes(finalStatus) ? 0 : 1;
|
|
8414
|
+
exitCode = ['completed', 'superseded', 'project_in_progress'].includes(finalStatus) ? 0 : 1;
|
|
8096
8415
|
|
|
8097
8416
|
const finishedAt = new Date().toISOString();
|
|
8098
|
-
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}`, {
|
|
8099
8418
|
queue,
|
|
8100
8419
|
taskId: task.id,
|
|
8101
8420
|
status: finalStatus
|
|
8102
8421
|
});
|
|
8103
8422
|
const completedTask = {
|
|
8104
8423
|
...task,
|
|
8105
|
-
status: finalStatus,
|
|
8424
|
+
status: finalStatus === 'project_in_progress' ? 'queued' : finalStatus,
|
|
8106
8425
|
startedAt,
|
|
8107
8426
|
finishedAt,
|
|
8108
8427
|
attempts: (task.attempts ?? 0) + Math.max(dispatchAttempts.length, dispatch ? 1 : 0),
|
|
8109
8428
|
runPath: path.relative(root, runPath)
|
|
8110
8429
|
};
|
|
8430
|
+
if (finalStatus === 'project_in_progress') {
|
|
8431
|
+
completedTask.projectContinuedAt = finishedAt;
|
|
8432
|
+
completedTask.projectContinuationCount = (task.projectContinuationCount ?? 0) + 1;
|
|
8433
|
+
}
|
|
8111
8434
|
const completedFile = path.join(destination, path.basename(activeFile));
|
|
8112
8435
|
await writeJson(completedFile, completedTask);
|
|
8113
8436
|
await rm(activeFile, { force: true });
|
|
@@ -8181,6 +8504,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8181
8504
|
} : null,
|
|
8182
8505
|
verification,
|
|
8183
8506
|
staleRecovered,
|
|
8507
|
+
orphanRecovered,
|
|
8184
8508
|
progress: progress.events,
|
|
8185
8509
|
taskPath: path.relative(root, completedFile),
|
|
8186
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.0",
|
|
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');
|
|
@@ -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 {
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
normalizeGoalDecision,
|
|
11
11
|
notifyHumanInputRequests,
|
|
12
12
|
notifyTerminalTasks,
|
|
13
|
+
queueHumanDecision,
|
|
14
|
+
queueStatus,
|
|
13
15
|
queueSubdirFor,
|
|
14
16
|
readJson,
|
|
15
17
|
routeLoopMessage,
|
|
@@ -76,6 +78,97 @@ const contract = await readJson(path.join(taskRuntimeDirFor(root, queue, routed.
|
|
|
76
78
|
assert.equal(contract.risk_level, 'model_assessed');
|
|
77
79
|
assert.equal(contract.requires_human_gate, false);
|
|
78
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
|
+
|
|
123
|
+
const reviewQueue = 'human-review-smoke';
|
|
124
|
+
const reviewTask = await routeLoopMessage(root, {
|
|
125
|
+
route: true,
|
|
126
|
+
confirmExecute: true,
|
|
127
|
+
queue: reviewQueue,
|
|
128
|
+
message: '走 loop 生成需要人工验收的交付物',
|
|
129
|
+
sourceChannel: 'feishu',
|
|
130
|
+
sourceTarget: 'user-1'
|
|
131
|
+
});
|
|
132
|
+
let reviewCheckpointWrite = null;
|
|
133
|
+
const reviewRun = await runQueueOnce(root, {
|
|
134
|
+
queue: reviewQueue,
|
|
135
|
+
dispatcher: '/bin/sleep 0.1',
|
|
136
|
+
requiresHumanGate: true,
|
|
137
|
+
timeoutMs: 10_000,
|
|
138
|
+
leaseMs: 20_000,
|
|
139
|
+
staleActiveMs: 60_000,
|
|
140
|
+
onProgress: (event) => {
|
|
141
|
+
if (event.phase !== 'dispatch' || event.status !== 'running' || reviewCheckpointWrite) return;
|
|
142
|
+
reviewCheckpointWrite = writeJson(path.join(taskRuntimeDirFor(root, reviewQueue, reviewTask.task.id), 'checkpoints', 'cp-review.json'), {
|
|
143
|
+
version: 1,
|
|
144
|
+
task_id: reviewTask.task.id,
|
|
145
|
+
checkpoint_id: 'cp-review',
|
|
146
|
+
status: 'ready_for_acceptance',
|
|
147
|
+
summary: 'The deliverable is complete and explicitly awaits human acceptance.',
|
|
148
|
+
files_changed: [],
|
|
149
|
+
verification: [{ command: '/bin/true', outcome: 'passed' }],
|
|
150
|
+
blockers: [],
|
|
151
|
+
risks: [],
|
|
152
|
+
next_action: 'human_acceptance'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
await reviewCheckpointWrite;
|
|
157
|
+
assert.equal(reviewRun.status, 'ready_for_human_review');
|
|
158
|
+
assert.match(reviewRun.taskPath, /failed/);
|
|
159
|
+
const reviewStatus = await queueStatus(root, reviewQueue);
|
|
160
|
+
assert.equal(reviewStatus.done, 0);
|
|
161
|
+
assert.equal(reviewStatus.failed, 1);
|
|
162
|
+
const reviewNotice = await notifyTerminalTasks(root, { queue: reviewQueue, dryRun: true });
|
|
163
|
+
assert.equal(reviewNotice.results[0].outcome, 'dry_run');
|
|
164
|
+
assert.match(reviewNotice.results[0].message, /ready for human acceptance/);
|
|
165
|
+
assert.match(reviewNotice.results[0].message, /approve, request_changes, or reject/);
|
|
166
|
+
const reviewDecision = await queueHumanDecision(root, reviewQueue, reviewTask.task.id, { decision: 'approve' });
|
|
167
|
+
assert.equal(reviewDecision.transitionedTask.status, 'completed');
|
|
168
|
+
const approvedStatus = await queueStatus(root, reviewQueue);
|
|
169
|
+
assert.equal(approvedStatus.done, 1);
|
|
170
|
+
assert.equal(approvedStatus.failed, 0);
|
|
171
|
+
|
|
79
172
|
for (const suffix of ['second', 'third']) {
|
|
80
173
|
await routeLoopMessage(root, {
|
|
81
174
|
route: true,
|
|
@@ -281,10 +374,45 @@ const gateId = gateSent.results[0].gateId;
|
|
|
281
374
|
const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1' });
|
|
282
375
|
assert.equal(resolved.outcome, 'resolved_and_requeued');
|
|
283
376
|
const requeuedTask = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
|
|
284
|
-
assert.equal(requeuedTask.humanInput.
|
|
285
|
-
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);
|
|
286
414
|
await writeJson(failedFile, { ...requeuedTask, status: 'needs_human_input' });
|
|
287
|
-
await rm(
|
|
415
|
+
await rm(inboxRequeued, { force: true });
|
|
288
416
|
|
|
289
417
|
const dryRun = await notifyTerminalTasks(root, { queue, dryRun: true });
|
|
290
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
|