taskforce-loop-engineering 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +5 -0
- package/bin/loop-engineering.mjs +3 -1
- package/lib/core.mjs +105 -35
- package/package.json +1 -1
- package/scripts/final-judgement-self-test.mjs +14 -0
- package/scripts/route-notify-self-test.mjs +51 -2
- package/skills/taskforce-loop-engineering/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.4 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
- Reclaim queue locks immediately when their recorded owner PID no longer
|
|
6
|
+
exists, even if the lease has not expired. Scheduler status no longer reports
|
|
7
|
+
those dead-owner locks as live, and an orphaned `active/` task forces recovery
|
|
8
|
+
on the next timer wake-up instead of waiting for scheduler backoff or lease
|
|
9
|
+
expiry.
|
|
10
|
+
|
|
11
|
+
- Distinguish one-time secret human inputs from durable non-sensitive decisions
|
|
12
|
+
and attestations, so review approvals remain available as structured evidence
|
|
13
|
+
while OTPs, passwords, tokens, and credential values are still destroyed after
|
|
14
|
+
consumption.
|
|
15
|
+
- Restrict human-gate reconciliation to the final judgement's effective
|
|
16
|
+
checkpoint set for every outcome, preventing a project-in-progress tick from
|
|
17
|
+
resurrecting historical waiting gates after a newer checkpoint clears them.
|
|
18
|
+
- Allow an explicitly superseded human gate to requeue a task from `waiting/`
|
|
19
|
+
through the normal queue CLI instead of requiring a manual file move.
|
|
20
|
+
|
|
21
|
+
## 0.8.3 - 2026-08-07
|
|
22
|
+
|
|
23
|
+
- Select the latest single-milestone checkpoint by durable checkpoint identity/sequence instead of regenerated acceptance-review timestamps, preventing a lexically late legacy `cp9` review from overriding a blocked `cp46+` checkpoint.
|
|
24
|
+
- Limit blocked human-input notification to the final judgement's effective checkpoint set, move blocked tasks from `failed/` into `waiting/`, and make the local Ironman scheduler run human-gate and terminal notification reconciliation after each notified tick.
|
|
25
|
+
|
|
3
26
|
## 0.8.2 - 2026-08-07
|
|
4
27
|
|
|
5
28
|
- Classify transcript-compaction timeouts and selected transport failures as recoverable runtime interruptions instead of development revisions. Preserve accepted checkpoints, rotate the worker session, and return the same project task to the queue with bounded recovery attempts.
|
package/README.md
CHANGED
|
@@ -370,6 +370,11 @@ loop-engineering queue-human-input-resolve \
|
|
|
370
370
|
The human-input notifier scans active and terminal tasks, sends the concrete
|
|
371
371
|
checkpoint blocker, and records a `waiting_for_human` gate. Resolution is
|
|
372
372
|
idempotent; a terminal blocked task is requeued with the response attached.
|
|
373
|
+
OTP, password, token, credential, and verification-code gates are inferred as
|
|
374
|
+
one-time secrets and destroyed after consumption. Review decisions, approvals,
|
|
375
|
+
assignments, and attestations remain available in the gate event as durable
|
|
376
|
+
non-sensitive evidence. Use `--secret-input` or `--non-secret-input` to override
|
|
377
|
+
the inference when gate wording is ambiguous.
|
|
373
378
|
|
|
374
379
|
Goal-directed controllers can use the exported `normalizeGoalDecision`,
|
|
375
380
|
`goalLoopTransition`, and `goalStrategyFingerprint` helpers. They distinguish
|
package/bin/loop-engineering.mjs
CHANGED
|
@@ -154,6 +154,8 @@ function parseArgs(argv) {
|
|
|
154
154
|
else if (a === '--notify-command') args.notifyCommand = argv[++i];
|
|
155
155
|
else if (a === '--gate-id') args.gateId = argv[++i];
|
|
156
156
|
else if (a === '--input') args.input = argv[++i];
|
|
157
|
+
else if (a === '--secret-input') args.secretInput = true;
|
|
158
|
+
else if (a === '--non-secret-input') args.nonSecretInput = true;
|
|
157
159
|
else if (a === '--source-channel') args.sourceChannel = argv[++i];
|
|
158
160
|
else if (a === '--source-target') args.sourceTarget = argv[++i];
|
|
159
161
|
else if (a === '--source-account') args.sourceAccount = argv[++i];
|
|
@@ -2077,7 +2079,7 @@ async function queueRequeueCommand(args) {
|
|
|
2077
2079
|
if (!args.taskId) throw new Error('queue-requeue requires --task-id.');
|
|
2078
2080
|
const config = await loadQueueConfig(args.root, args.config);
|
|
2079
2081
|
const options = mergeQueueOptions(config, args);
|
|
2080
|
-
const result = await queueRequeue(args.root, options.queue, args.taskId);
|
|
2082
|
+
const result = await queueRequeue(args.root, options.queue, args.taskId, { from: args.from });
|
|
2081
2083
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
2082
2084
|
else console.log(`requeued ${result.taskId}: ${result.file}`);
|
|
2083
2085
|
return 0;
|
package/lib/core.mjs
CHANGED
|
@@ -1822,7 +1822,18 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1822
1822
|
for (const [taskId, entry] of tasks) {
|
|
1823
1823
|
if (!entry.task.source) continue;
|
|
1824
1824
|
const checkpointsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'checkpoints');
|
|
1825
|
-
|
|
1825
|
+
let checkpointFiles = await listJson(checkpointsDir);
|
|
1826
|
+
const judgementFile = path.join(taskRuntimeDirFor(root, queue, taskId), 'final_judgement.json');
|
|
1827
|
+
if (await exists(judgementFile)) {
|
|
1828
|
+
const judgement = await readJson(judgementFile);
|
|
1829
|
+
const effectiveIds = Array.isArray(judgement?.coverage?.effective_review_ids)
|
|
1830
|
+
? new Set(judgement.coverage.effective_review_ids)
|
|
1831
|
+
: null;
|
|
1832
|
+
if (effectiveIds?.size > 0) {
|
|
1833
|
+
checkpointFiles = checkpointFiles.filter((file) => effectiveIds.has(path.basename(file, '.json')));
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
for (const file of checkpointFiles) {
|
|
1826
1837
|
const checkpoint = await readJson(path.join(checkpointsDir, file));
|
|
1827
1838
|
if (!['needs_human_input', 'blocked'].includes(checkpoint?.status)) continue;
|
|
1828
1839
|
const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
|
|
@@ -1830,17 +1841,17 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1830
1841
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
1831
1842
|
if (await exists(ledgerFile)) {
|
|
1832
1843
|
const gate = await readJson(ledgerFile);
|
|
1833
|
-
if (gate.status === 'waiting_for_human' && entry.subdir
|
|
1834
|
-
const
|
|
1835
|
-
if (await exists(
|
|
1836
|
-
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(
|
|
1844
|
+
if (gate.status === 'waiting_for_human' && ['inbox', 'failed'].includes(entry.subdir)) {
|
|
1845
|
+
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
1846
|
+
if (await exists(sourceFile)) {
|
|
1847
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
1837
1848
|
await writeJson(waitingFile, {
|
|
1838
1849
|
...entry.task,
|
|
1839
1850
|
status: 'waiting_for_human',
|
|
1840
1851
|
waitingGateId: gateId,
|
|
1841
1852
|
waitingSince: gate.requested_at ?? new Date().toISOString()
|
|
1842
1853
|
});
|
|
1843
|
-
await rm(
|
|
1854
|
+
await rm(sourceFile, { force: true });
|
|
1844
1855
|
}
|
|
1845
1856
|
}
|
|
1846
1857
|
results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
@@ -1880,17 +1891,17 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1880
1891
|
requested_at: new Date().toISOString(),
|
|
1881
1892
|
notification: compactCommandResult(result)
|
|
1882
1893
|
});
|
|
1883
|
-
if (entry.subdir
|
|
1884
|
-
const
|
|
1885
|
-
if (await exists(
|
|
1886
|
-
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(
|
|
1894
|
+
if (['inbox', 'failed'].includes(entry.subdir)) {
|
|
1895
|
+
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
1896
|
+
if (await exists(sourceFile)) {
|
|
1897
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
1887
1898
|
await writeJson(waitingFile, {
|
|
1888
1899
|
...entry.task,
|
|
1889
1900
|
status: 'waiting_for_human',
|
|
1890
1901
|
waitingGateId: gateId,
|
|
1891
1902
|
waitingSince: new Date().toISOString()
|
|
1892
1903
|
});
|
|
1893
|
-
await rm(
|
|
1904
|
+
await rm(sourceFile, { force: true });
|
|
1894
1905
|
}
|
|
1895
1906
|
}
|
|
1896
1907
|
results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
|
|
@@ -1921,28 +1932,40 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1921
1932
|
if (['resolved', 'consumed', 'satisfied'].includes(gate.status)) return { gate, outcome: 'already_resolved', ledger: path.relative(root, ledgerFile) };
|
|
1922
1933
|
const receivedAt = new Date().toISOString();
|
|
1923
1934
|
const response = options.input.trim();
|
|
1935
|
+
const requestText = `${gate.request ?? ''}\n${JSON.stringify(gate.needed ?? '')}`.toLowerCase();
|
|
1936
|
+
const inferredSecret = /(?:otp|one[- ]time|verification code|sms code|验证码|校验码|口令|password|api[ _-]?key|access[ _-]?token|secret|credential value|密钥|令牌)/i.test(requestText);
|
|
1937
|
+
const inputKind = options.secretInput === true
|
|
1938
|
+
? 'secret'
|
|
1939
|
+
: options.nonSecretInput === true
|
|
1940
|
+
? 'attestation'
|
|
1941
|
+
: inferredSecret ? 'secret' : 'attestation';
|
|
1942
|
+
const secretReceived = inputKind === 'secret';
|
|
1924
1943
|
const secretDir = path.join(queueDirFor(root, queue), 'human-input', 'secrets');
|
|
1925
1944
|
const eventsDir = path.join(queueDirFor(root, queue), 'human-input', 'events');
|
|
1926
|
-
await mkdir(secretDir, { recursive: true, mode: 0o700 });
|
|
1927
1945
|
await mkdir(eventsDir, { recursive: true });
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1946
|
+
let secretFile = null;
|
|
1947
|
+
if (secretReceived) {
|
|
1948
|
+
await mkdir(secretDir, { recursive: true, mode: 0o700 });
|
|
1949
|
+
secretFile = path.join(secretDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.secret`);
|
|
1950
|
+
const handle = await open(secretFile, 'wx', 0o600);
|
|
1951
|
+
try {
|
|
1952
|
+
await handle.writeFile(response);
|
|
1953
|
+
} finally {
|
|
1954
|
+
await handle.close();
|
|
1955
|
+
}
|
|
1934
1956
|
}
|
|
1935
1957
|
const responseSha256 = createHash('sha256').update(response).digest('hex');
|
|
1936
1958
|
const resolved = {
|
|
1937
1959
|
...gate,
|
|
1938
1960
|
status: 'resolved',
|
|
1939
|
-
|
|
1961
|
+
input_kind: inputKind,
|
|
1962
|
+
secret_received: secretReceived,
|
|
1940
1963
|
response_sha256: responseSha256,
|
|
1941
|
-
response_ref: path.relative(root, secretFile),
|
|
1964
|
+
...(secretReceived ? { response_ref: path.relative(root, secretFile) } : { response }),
|
|
1942
1965
|
response_message_id: options.sourceMessageId ?? null,
|
|
1943
1966
|
resolved_at: receivedAt
|
|
1944
1967
|
};
|
|
1945
|
-
delete resolved.response;
|
|
1968
|
+
if (secretReceived) delete resolved.response;
|
|
1946
1969
|
await writeJson(ledgerFile, resolved);
|
|
1947
1970
|
const eventFile = path.join(eventsDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.json`);
|
|
1948
1971
|
await writeJson(eventFile, {
|
|
@@ -1952,9 +1975,10 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1952
1975
|
task_id: taskId,
|
|
1953
1976
|
checkpoint_id: checkpointId,
|
|
1954
1977
|
status: 'pending_consumption',
|
|
1955
|
-
|
|
1978
|
+
input_kind: inputKind,
|
|
1979
|
+
secret_received: secretReceived,
|
|
1956
1980
|
response_sha256: responseSha256,
|
|
1957
|
-
response_ref: path.relative(root, secretFile),
|
|
1981
|
+
...(secretReceived ? { response_ref: path.relative(root, secretFile) } : { response }),
|
|
1958
1982
|
response_message_id: options.sourceMessageId ?? null,
|
|
1959
1983
|
created_at: receivedAt
|
|
1960
1984
|
});
|
|
@@ -1968,7 +1992,8 @@ export async function resolveHumanInput(root, options = {}) {
|
|
|
1968
1992
|
humanInput: {
|
|
1969
1993
|
gate_id: options.gateId,
|
|
1970
1994
|
checkpoint_id: checkpointId,
|
|
1971
|
-
|
|
1995
|
+
input_kind: inputKind,
|
|
1996
|
+
secret_received: secretReceived,
|
|
1972
1997
|
response_sha256: responseSha256,
|
|
1973
1998
|
event: path.relative(root, eventFile),
|
|
1974
1999
|
received_at: receivedAt
|
|
@@ -2019,8 +2044,10 @@ async function prepareHumanInputContext(root, queue, taskId) {
|
|
|
2019
2044
|
checkpoint_id: consumed.checkpoint_id,
|
|
2020
2045
|
status: consumed.status,
|
|
2021
2046
|
secret_received: Boolean(consumed.secret_received),
|
|
2047
|
+
input_kind: consumed.input_kind ?? (consumed.secret_received ? 'secret' : 'attestation'),
|
|
2022
2048
|
response_sha256: consumed.response_sha256 ?? null,
|
|
2023
2049
|
response_ref: consumed.response_ref ? path.join(root, consumed.response_ref) : null,
|
|
2050
|
+
response: consumed.secret_received ? null : consumed.response ?? null,
|
|
2024
2051
|
resolved_at: consumed.resolved_at ?? null,
|
|
2025
2052
|
consumed_at: consumed.consumed_at ?? null
|
|
2026
2053
|
});
|
|
@@ -2845,6 +2872,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
2845
2872
|
milestoneId: checkpoint.milestone_id ?? null,
|
|
2846
2873
|
revisesCheckpointId: checkpoint.revises_checkpoint_id ?? null,
|
|
2847
2874
|
sequence: Number(checkpoint.sequence ?? String(checkpoint.checkpoint_id ?? '').match(/(\d+)$/)?.[1] ?? 0),
|
|
2875
|
+
checkpointCreatedAt: checkpoint.created_at ?? null,
|
|
2848
2876
|
createdAt: checkpoint.created_at ?? review.created_at,
|
|
2849
2877
|
projectCompletion: checkpoint.project_completion ?? null,
|
|
2850
2878
|
status: review.status,
|
|
@@ -2871,11 +2899,16 @@ function compareReviewSequence(a, b) {
|
|
|
2871
2899
|
}
|
|
2872
2900
|
|
|
2873
2901
|
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
2902
|
const aCheckpoint = Number(String(a.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
|
|
2877
2903
|
const bCheckpoint = Number(String(b.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
|
|
2878
2904
|
if (aCheckpoint !== bCheckpoint) return aCheckpoint - bCheckpoint;
|
|
2905
|
+
// Review artifacts are regenerated as a batch. Their generated_at values
|
|
2906
|
+
// therefore describe directory traversal order, not checkpoint recency.
|
|
2907
|
+
// Only trust a timestamp that came from the checkpoint itself.
|
|
2908
|
+
const checkpointCreatedDelta = String(a.checkpointCreatedAt ?? '').localeCompare(String(b.checkpointCreatedAt ?? ''));
|
|
2909
|
+
if (checkpointCreatedDelta !== 0 && a.checkpointCreatedAt && b.checkpointCreatedAt) return checkpointCreatedDelta;
|
|
2910
|
+
const sequenceDelta = Number(a.sequence ?? 0) - Number(b.sequence ?? 0);
|
|
2911
|
+
if (sequenceDelta !== 0) return sequenceDelta;
|
|
2879
2912
|
return compareReviewSequence(a, b);
|
|
2880
2913
|
}
|
|
2881
2914
|
|
|
@@ -3174,6 +3207,7 @@ export async function queueStatus(root, queue) {
|
|
|
3174
3207
|
await ensureQueueDirs(root, queue);
|
|
3175
3208
|
const activeFiles = await listJson(queueSubdirFor(root, queue, 'active'));
|
|
3176
3209
|
const lock = await readQueueLock(root, queue);
|
|
3210
|
+
const lockOwnerAlive = queueLockOwnerAlive(lock);
|
|
3177
3211
|
return {
|
|
3178
3212
|
queue,
|
|
3179
3213
|
queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
|
|
@@ -3183,8 +3217,10 @@ export async function queueStatus(root, queue) {
|
|
|
3183
3217
|
failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
|
|
3184
3218
|
canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
|
|
3185
3219
|
runs: (await listJson(queueSubdirFor(root, queue, 'runs'))).length,
|
|
3186
|
-
locked: Boolean(lock && Date.parse(lock.expiresAt) > Date.now()),
|
|
3187
|
-
lockExpiresAt: lock?.expiresAt ?? null
|
|
3220
|
+
locked: Boolean(lock && Date.parse(lock.expiresAt) > Date.now() && lockOwnerAlive),
|
|
3221
|
+
lockExpiresAt: lock?.expiresAt ?? null,
|
|
3222
|
+
lockPid: lock?.pid ?? null,
|
|
3223
|
+
lockOwnerAlive
|
|
3188
3224
|
};
|
|
3189
3225
|
}
|
|
3190
3226
|
|
|
@@ -3492,7 +3528,13 @@ export async function queueSchedulerTick(root, options) {
|
|
|
3492
3528
|
const previous = await readQueueSchedulerState(root, queue);
|
|
3493
3529
|
const previousProgress = await readQueueProgressState(root, queue);
|
|
3494
3530
|
const statusBefore = await queueStatus(root, queue);
|
|
3495
|
-
|
|
3531
|
+
// An active task without a live lock owner is an orphan. Do not let a
|
|
3532
|
+
// previously scheduled backoff postpone recovery until nextRunAt.
|
|
3533
|
+
const orphanedActive = statusBefore.active > 0 && !statusBefore.locked;
|
|
3534
|
+
const due = orphanedActive
|
|
3535
|
+
|| !previous?.nextRunAt
|
|
3536
|
+
|| Date.parse(previous.nextRunAt) <= nowMs
|
|
3537
|
+
|| Boolean(options.forceDue);
|
|
3496
3538
|
let runResult = null;
|
|
3497
3539
|
let executed = false;
|
|
3498
3540
|
let status = due ? 'due' : 'not_due';
|
|
@@ -3751,11 +3793,27 @@ async function readQueueLock(root, queue) {
|
|
|
3751
3793
|
}
|
|
3752
3794
|
}
|
|
3753
3795
|
|
|
3796
|
+
function processIsAlive(pid) {
|
|
3797
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
3798
|
+
try {
|
|
3799
|
+
process.kill(pid, 0);
|
|
3800
|
+
return true;
|
|
3801
|
+
} catch (err) {
|
|
3802
|
+
// EPERM means the process exists but this user cannot signal it.
|
|
3803
|
+
return Boolean(err && err.code === 'EPERM');
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
function queueLockOwnerAlive(lock) {
|
|
3808
|
+
return Boolean(lock && processIsAlive(lock.pid));
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3754
3811
|
async function acquireQueueLock(root, queue, leaseMs) {
|
|
3755
3812
|
const lockFile = path.join(queueDirFor(root, queue), 'queue.lock');
|
|
3756
3813
|
const now = Date.now();
|
|
3757
3814
|
const existing = await readQueueLock(root, queue);
|
|
3758
|
-
|
|
3815
|
+
const existingOwnerAlive = queueLockOwnerAlive(existing);
|
|
3816
|
+
if (existing && Date.parse(existing.expiresAt) > now && existingOwnerAlive) {
|
|
3759
3817
|
return { acquired: false, lock: existing };
|
|
3760
3818
|
}
|
|
3761
3819
|
if (existing) await rm(lockFile, { force: true });
|
|
@@ -3770,7 +3828,14 @@ async function acquireQueueLock(root, queue, leaseMs) {
|
|
|
3770
3828
|
try {
|
|
3771
3829
|
handle = await open(lockFile, 'wx');
|
|
3772
3830
|
await handle.writeFile(`${JSON.stringify(lock, null, 2)}\n`);
|
|
3773
|
-
return {
|
|
3831
|
+
return {
|
|
3832
|
+
acquired: true,
|
|
3833
|
+
lock,
|
|
3834
|
+
reclaimedLock: existing ? {
|
|
3835
|
+
lock: existing,
|
|
3836
|
+
reason: existingOwnerAlive ? 'expired_lease' : 'dead_owner_pid'
|
|
3837
|
+
} : null
|
|
3838
|
+
};
|
|
3774
3839
|
} catch (err) {
|
|
3775
3840
|
if (err && err.code === 'EEXIST') {
|
|
3776
3841
|
return { acquired: false, lock: await readQueueLock(root, queue) };
|
|
@@ -3846,7 +3911,9 @@ export async function queueCancel(root, queue, taskId, options = {}) {
|
|
|
3846
3911
|
export async function queueRequeue(root, queue, taskId, options = {}) {
|
|
3847
3912
|
const normalized = normalizeLoopId(queue);
|
|
3848
3913
|
await ensureQueueDirs(root, normalized);
|
|
3849
|
-
const
|
|
3914
|
+
const allowedSources = ['failed', 'active', 'waiting', 'canceled'];
|
|
3915
|
+
if (options.from && !allowedSources.includes(options.from)) throw new Error(`Unsupported requeue source: ${options.from}`);
|
|
3916
|
+
const found = await findTaskFile(root, normalized, taskId, options.from ? [options.from] : allowedSources);
|
|
3850
3917
|
if (!found) throw new Error(`Task not found in requeueable state: ${taskId}`);
|
|
3851
3918
|
const task = await readJson(found.file);
|
|
3852
3919
|
const inboxFile = path.join(queueSubdirFor(root, normalized, 'inbox'), path.basename(found.file));
|
|
@@ -7757,7 +7824,7 @@ async function recoverStaleActive(root, queue, staleActiveMs) {
|
|
|
7757
7824
|
return recovered;
|
|
7758
7825
|
}
|
|
7759
7826
|
|
|
7760
|
-
async function recoverOrphanActive(root, queue) {
|
|
7827
|
+
async function recoverOrphanActive(root, queue, recoveryReason = 'queue_lock_reacquired_with_active_task') {
|
|
7761
7828
|
const activeDir = queueSubdirFor(root, queue, 'active');
|
|
7762
7829
|
const inboxDir = queueSubdirFor(root, queue, 'inbox');
|
|
7763
7830
|
const files = await listJson(activeDir);
|
|
@@ -7776,7 +7843,7 @@ async function recoverOrphanActive(root, queue) {
|
|
|
7776
7843
|
orphanRecoveryCount: (task.orphanRecoveryCount ?? 0) + 1,
|
|
7777
7844
|
requeuedAt: recoveredAt,
|
|
7778
7845
|
requeuedFrom: 'active',
|
|
7779
|
-
recoveryReason
|
|
7846
|
+
recoveryReason
|
|
7780
7847
|
});
|
|
7781
7848
|
recovered.push({
|
|
7782
7849
|
taskId: task.id,
|
|
@@ -8176,7 +8243,10 @@ export async function runQueueOnce(root, options) {
|
|
|
8176
8243
|
// queue lease. Any task left in active/ is therefore an orphan from an
|
|
8177
8244
|
// interrupted parent runner. Requeue it immediately so existing
|
|
8178
8245
|
// checkpoints can be reviewed and execution can resume in this tick.
|
|
8179
|
-
const
|
|
8246
|
+
const recoveryReason = lockResult.reclaimedLock?.reason === 'dead_owner_pid'
|
|
8247
|
+
? 'dead_queue_lock_owner_pid'
|
|
8248
|
+
: 'queue_lock_reacquired_with_active_task';
|
|
8249
|
+
const orphanRecovered = await recoverOrphanActive(root, queue, recoveryReason);
|
|
8180
8250
|
if (orphanRecovered.length > 0) {
|
|
8181
8251
|
progress.emit('queue', 'orphan_recovered', `Recovered ${orphanRecovered.length} orphan active task(s)`, {
|
|
8182
8252
|
queue,
|
package/package.json
CHANGED
|
@@ -16,6 +16,20 @@ const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: fal
|
|
|
16
16
|
assert.equal(selectEffectiveAcceptanceReviews(devPlan, reviews)[0].checkpointId, 'cp10');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
{
|
|
20
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
21
|
+
const reviews = {
|
|
22
|
+
reviews: [
|
|
23
|
+
// Acceptance reviews are regenerated in filename traversal order. A
|
|
24
|
+
// legacy cp9 review can consequently have a later review timestamp than
|
|
25
|
+
// the real latest cp46 checkpoint; that timestamp must not win.
|
|
26
|
+
{ checkpointId: 'cp46', sequence: 9, createdAt: '2026-08-07T18:27:18.990Z', status: 'blocked' },
|
|
27
|
+
{ checkpointId: 'cp9', sequence: 1, createdAt: '2026-08-07T18:27:18.992Z', status: 'accepted' }
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
assert.equal(selectEffectiveAcceptanceReviews(devPlan, reviews)[0].checkpointId, 'cp46');
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
{
|
|
20
34
|
const classification = dispatchFailureClassification({
|
|
21
35
|
exitCode: 1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtemp, readdir, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { access, 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 {
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
notifyHumanInputRequests,
|
|
12
12
|
notifyTerminalTasks,
|
|
13
13
|
queueHumanDecision,
|
|
14
|
+
queueDirFor,
|
|
14
15
|
queueStatus,
|
|
15
16
|
queueSubdirFor,
|
|
16
17
|
readJson,
|
|
@@ -92,6 +93,14 @@ const orphanInbox = path.join(queueSubdirFor(root, orphanQueue, 'inbox'), orphan
|
|
|
92
93
|
const orphanActive = path.join(queueSubdirFor(root, orphanQueue, 'active'), orphanName);
|
|
93
94
|
await writeJson(orphanActive, { ...(await readJson(orphanInbox)), status: 'active', startedAt: new Date().toISOString() });
|
|
94
95
|
await rm(orphanInbox, { force: true });
|
|
96
|
+
// A future lease owned by a dead PID must not hide the orphan until expiry.
|
|
97
|
+
await writeJson(path.join(queueDirFor(root, orphanQueue), 'queue.lock'), {
|
|
98
|
+
version: 1,
|
|
99
|
+
queue: orphanQueue,
|
|
100
|
+
pid: 2_147_483_647,
|
|
101
|
+
acquiredAt: new Date().toISOString(),
|
|
102
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString()
|
|
103
|
+
});
|
|
95
104
|
await writeJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'), {
|
|
96
105
|
version: 1,
|
|
97
106
|
task_id: orphanRouted.task.id,
|
|
@@ -118,6 +127,7 @@ assert.ok(orphanRun.progress.some((event) => event.status === 'orphan_recovered'
|
|
|
118
127
|
const orphanTerminal = await readJson(path.join(root, orphanRun.taskPath));
|
|
119
128
|
assert.equal(orphanTerminal.orphanRecoveryCount, 1);
|
|
120
129
|
assert.equal(orphanTerminal.requeuedFrom, 'active');
|
|
130
|
+
assert.equal(orphanTerminal.recoveryReason, 'dead_queue_lock_owner_pid');
|
|
121
131
|
assert.equal((await readJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'))).checkpoint_id, 'cp-before-crash');
|
|
122
132
|
|
|
123
133
|
const reviewQueue = 'human-review-smoke';
|
|
@@ -371,7 +381,9 @@ assert.match(gateDryRun.results[0].message, /Provide the SMS code/);
|
|
|
371
381
|
const gateSent = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
372
382
|
assert.equal(gateSent.sent, 1);
|
|
373
383
|
const gateId = gateSent.results[0].gateId;
|
|
374
|
-
|
|
384
|
+
assert.equal((await readJson(path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(failedFile)))).status, 'waiting_for_human');
|
|
385
|
+
await assert.rejects(access(failedFile));
|
|
386
|
+
const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1', secretInput: true });
|
|
375
387
|
assert.equal(resolved.outcome, 'resolved_and_requeued');
|
|
376
388
|
const requeuedTask = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
|
|
377
389
|
assert.equal(requeuedTask.humanInput.secret_received, true);
|
|
@@ -380,6 +392,43 @@ const resolvedGate = await readJson(path.join(root, resolved.ledger ?? gateSent.
|
|
|
380
392
|
assert.equal(JSON.stringify(resolvedGate).includes('123456'), false);
|
|
381
393
|
assert.equal(resolvedGate.response_sha256.length, 64);
|
|
382
394
|
|
|
395
|
+
// Non-sensitive decisions and attestations remain available to the next worker
|
|
396
|
+
// tick instead of being destroyed like OTPs and credentials.
|
|
397
|
+
const attestationCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-attestation.json');
|
|
398
|
+
await writeJson(attestationCheckpoint, {
|
|
399
|
+
version: 1, task_id: routed.task.id, checkpoint_id: 'cp-attestation', status: 'needs_human_input',
|
|
400
|
+
blockers: [{ id: 'review-decision', reason: 'Provide the independent review decision.' }],
|
|
401
|
+
verification: [], risks: [], next_action: 'wait'
|
|
402
|
+
});
|
|
403
|
+
await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
404
|
+
const attestationGateId = `${routed.task.id}:cp-attestation`;
|
|
405
|
+
const attestationResolved = await resolveHumanInput(root, {
|
|
406
|
+
queue,
|
|
407
|
+
gateId: attestationGateId,
|
|
408
|
+
input: 'Reviewer accepts the candidate for the next gated stage.'
|
|
409
|
+
});
|
|
410
|
+
assert.equal(attestationResolved.gate.input_kind, 'attestation');
|
|
411
|
+
assert.equal(attestationResolved.gate.secret_received, false);
|
|
412
|
+
assert.equal(attestationResolved.gate.response, 'Reviewer accepts the candidate for the next gated stage.');
|
|
413
|
+
assert.equal(attestationResolved.gate.response_ref, undefined);
|
|
414
|
+
|
|
415
|
+
// A project-in-progress judgement with a newer effective checkpoint must not
|
|
416
|
+
// resurrect historical waiting gates from older checkpoints.
|
|
417
|
+
const attestationDoneCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-attestation-done.json');
|
|
418
|
+
await writeJson(attestationDoneCheckpoint, {
|
|
419
|
+
version: 1, task_id: routed.task.id, checkpoint_id: 'cp-attestation-done',
|
|
420
|
+
revises_checkpoint_id: 'cp-attestation', sequence: 2, status: 'ready_for_acceptance',
|
|
421
|
+
blockers: [], verification: [{ observation: 'attestation', result: 'accepted' }], risks: [], next_action: 'continue'
|
|
422
|
+
});
|
|
423
|
+
const finalJudgementFile = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'final_judgement.json');
|
|
424
|
+
await writeJson(finalJudgementFile, {
|
|
425
|
+
version: 1, task_id: routed.task.id, outcome: 'project_in_progress',
|
|
426
|
+
coverage: { effective_review_ids: ['cp-attestation-done'] }
|
|
427
|
+
});
|
|
428
|
+
const noHistoricalGateReplay = await notifyHumanInputRequests(root, { queue, dryRun: true });
|
|
429
|
+
assert.equal(noHistoricalGateReplay.inspected, 0);
|
|
430
|
+
await rm(finalJudgementFile, { force: true });
|
|
431
|
+
|
|
383
432
|
// Queued tasks receive a non-sensitive event reference without being moved.
|
|
384
433
|
const queuedCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-queued.json');
|
|
385
434
|
await writeJson(queuedCheckpoint, {
|
|
@@ -216,7 +216,7 @@ loop-engineering queue-revision-next --root <workspace> --queue <queue> --task-i
|
|
|
216
216
|
loop-engineering queue-lineage --root <workspace> --queue <queue> --task-id <id>
|
|
217
217
|
loop-engineering queue-lineage-bundle --root <workspace> --queue <queue> --task-id <id>
|
|
218
218
|
loop-engineering queue-human-decision --root <workspace> --queue <queue> --task-id <id> --decision approve|request_changes|reject
|
|
219
|
-
loop-engineering queue-human-input-resolve --root <workspace> --queue <queue> --gate-id <task:checkpoint> --input "<response>"
|
|
219
|
+
loop-engineering queue-human-input-resolve --root <workspace> --queue <queue> --gate-id <task:checkpoint> --input "<response>" [--secret-input|--non-secret-input]
|
|
220
220
|
loop-engineering queue-terminal-notify --root <workspace> --queue <queue> (--notify-command "<command>" | --dry-run)
|
|
221
221
|
loop-engineering queue-human-input-notify --root <workspace> --queue <queue> (--notify-command "<command>" | --dry-run)
|
|
222
222
|
```
|