taskforce-loop-engineering 0.7.2 → 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 CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.7.2 - 2026-08-06
4
15
 
5
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.
package/lib/core.mjs CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { tmpdir } from 'node:os';
6
+ import { createHash } from 'node:crypto';
6
7
 
7
8
  export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
9
 
@@ -785,6 +786,24 @@ export async function doctorReport(root, options = {}) {
785
786
  preflightConfig: optionsForQueue.preflightConfig ?? null,
786
787
  status
787
788
  });
789
+ if (config.scheduler?.required === true && status.queued > 0) {
790
+ const schedulerState = await readQueueSchedulerState(root, optionsForQueue.queue);
791
+ const heartbeatMaxAgeMs = parseScheduleDurationMs(
792
+ config.scheduler.heartbeatMaxAge ?? '5m',
793
+ 'scheduler.heartbeatMaxAge'
794
+ );
795
+ const generatedAtMs = Date.parse(schedulerState?.generatedAt ?? '');
796
+ const ageMs = Number.isFinite(generatedAtMs) ? Math.max(0, Date.now() - generatedAtMs) : null;
797
+ const healthy = ageMs !== null && ageMs <= heartbeatMaxAgeMs;
798
+ add(`queue:${optionsForQueue.queue}:scheduler-heartbeat`, 'fail', healthy, {
799
+ code: healthy ? 'scheduler_healthy' : 'scheduler_missing',
800
+ queued: status.queued,
801
+ state: path.relative(root, queueSchedulerStatePath(root, optionsForQueue.queue)),
802
+ generatedAt: schedulerState?.generatedAt ?? null,
803
+ ageMs,
804
+ heartbeatMaxAgeMs
805
+ });
806
+ }
788
807
  if (status.locked) add(`queue:${optionsForQueue.queue}:lock`, 'warn', false, status.lockExpiresAt);
789
808
  if (status.active > 0) add(`queue:${optionsForQueue.queue}:active`, 'warn', false, `${status.active} active task(s)`);
790
809
  if (status.failed > 0) add(`queue:${optionsForQueue.queue}:failed`, 'warn', false, `${status.failed} failed task(s)`);
@@ -924,7 +943,7 @@ export function queueSubdirFor(root, queue, subdir) {
924
943
 
925
944
  export async function ensureQueueDirs(root, queue) {
926
945
  normalizeLoopId(queue);
927
- await Promise.all(['inbox', 'active', 'done', 'failed', 'runs', 'canceled', 'tasks']
946
+ await Promise.all(['inbox', 'active', 'waiting', 'done', 'failed', 'runs', 'canceled', 'tasks']
928
947
  .map((subdir) => mkdir(queueSubdirFor(root, queue, subdir), { recursive: true })));
929
948
  }
930
949
 
@@ -1422,14 +1441,15 @@ export async function projectStatus(root, options = {}) {
1422
1441
  backlog = null;
1423
1442
  }
1424
1443
  const totals = queues.reduce((acc, queue) => {
1425
- for (const key of ['queued', 'active', 'done', 'failed', 'canceled', 'runs']) {
1444
+ for (const key of ['queued', 'active', 'waiting', 'done', 'failed', 'canceled', 'runs']) {
1426
1445
  acc[key] += queue.status[key] ?? 0;
1427
1446
  }
1428
1447
  if (queue.status.locked) acc.locked += 1;
1429
1448
  return acc;
1430
- }, { queued: 0, active: 0, done: 0, failed: 0, canceled: 0, runs: 0, locked: 0 });
1449
+ }, { queued: 0, active: 0, waiting: 0, done: 0, failed: 0, canceled: 0, runs: 0, locked: 0 });
1431
1450
  const needsAttention = [];
1432
1451
  if (totals.failed > 0) needsAttention.push('failed_tasks_present');
1452
+ if (totals.waiting > 0) needsAttention.push('human_input_waiting');
1433
1453
  if (totals.active > 0) needsAttention.push('active_tasks_present');
1434
1454
  if (queues.some((queue) => queue.status.locked)) needsAttention.push('queue_locked');
1435
1455
  return {
@@ -1780,7 +1800,7 @@ function humanInputMessage(queue, task, checkpoint, gateId) {
1780
1800
 
1781
1801
  async function tasksById(root, queue) {
1782
1802
  const tasks = new Map();
1783
- for (const subdir of ['inbox', 'active', 'done', 'failed', 'canceled']) {
1803
+ for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
1784
1804
  for (const file of await listJson(queueSubdirFor(root, queue, subdir))) {
1785
1805
  const task = await readJson(path.join(queueSubdirFor(root, queue, subdir), file));
1786
1806
  if (task?.id) tasks.set(task.id, { task, subdir });
@@ -1810,6 +1830,19 @@ export async function notifyHumanInputRequests(root, options = {}) {
1810
1830
  const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
1811
1831
  if (await exists(ledgerFile)) {
1812
1832
  const gate = await readJson(ledgerFile);
1833
+ if (gate.status === 'waiting_for_human' && entry.subdir === 'inbox') {
1834
+ const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${safeTaskId(taskId)}.json`);
1835
+ if (await exists(inboxFile)) {
1836
+ const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(inboxFile));
1837
+ await writeJson(waitingFile, {
1838
+ ...entry.task,
1839
+ status: 'waiting_for_human',
1840
+ waitingGateId: gateId,
1841
+ waitingSince: gate.requested_at ?? new Date().toISOString()
1842
+ });
1843
+ await rm(inboxFile, { force: true });
1844
+ }
1845
+ }
1813
1846
  results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
1814
1847
  continue;
1815
1848
  }
@@ -1847,6 +1880,19 @@ export async function notifyHumanInputRequests(root, options = {}) {
1847
1880
  requested_at: new Date().toISOString(),
1848
1881
  notification: compactCommandResult(result)
1849
1882
  });
1883
+ if (entry.subdir === 'inbox') {
1884
+ const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), `${safeTaskId(taskId)}.json`);
1885
+ if (await exists(inboxFile)) {
1886
+ const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(inboxFile));
1887
+ await writeJson(waitingFile, {
1888
+ ...entry.task,
1889
+ status: 'waiting_for_human',
1890
+ waitingGateId: gateId,
1891
+ waitingSince: new Date().toISOString()
1892
+ });
1893
+ await rm(inboxFile, { force: true });
1894
+ }
1895
+ }
1850
1896
  results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
1851
1897
  }
1852
1898
  }
@@ -1872,37 +1918,153 @@ export async function resolveHumanInput(root, options = {}) {
1872
1918
  const ledgerFile = path.join(queueDirFor(root, queue), 'human-input', 'gates', `${safeTaskId(taskId)}.${checkpointId}.json`);
1873
1919
  if (!await exists(ledgerFile)) throw new Error(`Human-input gate not found: ${options.gateId}`);
1874
1920
  const gate = await readJson(ledgerFile);
1875
- if (gate.status === 'resolved') return { gate, outcome: 'already_resolved', ledger: path.relative(root, ledgerFile) };
1921
+ if (['resolved', 'consumed', 'satisfied'].includes(gate.status)) return { gate, outcome: 'already_resolved', ledger: path.relative(root, ledgerFile) };
1922
+ const receivedAt = new Date().toISOString();
1923
+ const response = options.input.trim();
1924
+ const secretDir = path.join(queueDirFor(root, queue), 'human-input', 'secrets');
1925
+ const eventsDir = path.join(queueDirFor(root, queue), 'human-input', 'events');
1926
+ await mkdir(secretDir, { recursive: true, mode: 0o700 });
1927
+ await mkdir(eventsDir, { recursive: true });
1928
+ const secretFile = path.join(secretDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.secret`);
1929
+ const handle = await open(secretFile, 'wx', 0o600);
1930
+ try {
1931
+ await handle.writeFile(response);
1932
+ } finally {
1933
+ await handle.close();
1934
+ }
1935
+ const responseSha256 = createHash('sha256').update(response).digest('hex');
1876
1936
  const resolved = {
1877
1937
  ...gate,
1878
1938
  status: 'resolved',
1879
- response: options.input.trim(),
1939
+ secret_received: true,
1940
+ response_sha256: responseSha256,
1941
+ response_ref: path.relative(root, secretFile),
1880
1942
  response_message_id: options.sourceMessageId ?? null,
1881
- resolved_at: new Date().toISOString()
1943
+ resolved_at: receivedAt
1882
1944
  };
1945
+ delete resolved.response;
1883
1946
  await writeJson(ledgerFile, resolved);
1884
- const found = await findTaskFile(root, queue, taskId, ['failed', 'canceled']);
1947
+ const eventFile = path.join(eventsDir, `${safeTaskId(taskId)}.${checkpointId}.${isoStamp()}.json`);
1948
+ await writeJson(eventFile, {
1949
+ version: 1,
1950
+ type: 'human_input_resolved',
1951
+ gate_id: options.gateId,
1952
+ task_id: taskId,
1953
+ checkpoint_id: checkpointId,
1954
+ status: 'pending_consumption',
1955
+ secret_received: true,
1956
+ response_sha256: responseSha256,
1957
+ response_ref: path.relative(root, secretFile),
1958
+ response_message_id: options.sourceMessageId ?? null,
1959
+ created_at: receivedAt
1960
+ });
1961
+ const found = await findTaskFile(root, queue, taskId, ['inbox', 'active', 'waiting', 'failed', 'canceled']);
1885
1962
  let requeued = null;
1963
+ let updated = null;
1886
1964
  if (found) {
1887
1965
  const task = await readJson(found.file);
1888
- const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(found.file));
1889
- await writeJson(inboxFile, {
1966
+ const taskUpdate = {
1890
1967
  ...task,
1891
- status: 'queued',
1892
- body: `${task.body}\n\nHuman input for gate ${options.gateId}:\n${options.input.trim()}`,
1893
1968
  humanInput: {
1894
1969
  gate_id: options.gateId,
1895
1970
  checkpoint_id: checkpointId,
1896
- response: options.input.trim(),
1897
- received_at: resolved.resolved_at
1898
- },
1899
- requeuedAt: resolved.resolved_at,
1900
- requeuedFrom: found.subdir
1971
+ secret_received: true,
1972
+ response_sha256: responseSha256,
1973
+ event: path.relative(root, eventFile),
1974
+ received_at: receivedAt
1975
+ }
1976
+ };
1977
+ if (['waiting', 'failed', 'canceled'].includes(found.subdir)) {
1978
+ const inboxFile = path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(found.file));
1979
+ await writeJson(inboxFile, {
1980
+ ...taskUpdate,
1981
+ status: 'queued',
1982
+ requeuedAt: receivedAt,
1983
+ requeuedFrom: found.subdir
1984
+ });
1985
+ await rm(found.file, { force: true });
1986
+ requeued = path.relative(root, inboxFile);
1987
+ } else if (found.subdir === 'inbox') {
1988
+ await writeJson(found.file, taskUpdate);
1989
+ updated = path.relative(root, found.file);
1990
+ } else {
1991
+ // An active dispatcher cannot be mutated safely. The durable event is
1992
+ // consumed on the next bounded tick after the active run finishes.
1993
+ updated = path.relative(root, eventFile);
1994
+ }
1995
+ }
1996
+ return {
1997
+ gate: resolved,
1998
+ outcome: requeued ? 'resolved_and_requeued' : found?.subdir === 'active' ? 'resolved_pending_safe_boundary' : 'resolved_for_next_tick',
1999
+ requeued,
2000
+ updated,
2001
+ event: path.relative(root, eventFile)
2002
+ };
2003
+ }
2004
+
2005
+ async function prepareHumanInputContext(root, queue, taskId) {
2006
+ const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
2007
+ const runtimeDir = taskRuntimeDirFor(root, queue, taskId);
2008
+ const contextFile = path.join(runtimeDir, 'human_input_context.json');
2009
+ const gates = [];
2010
+ for (const file of await listJson(gatesDir)) {
2011
+ const full = path.join(gatesDir, file);
2012
+ const gate = await readJson(full);
2013
+ if (gate.task_id !== taskId || !['resolved', 'consumed'].includes(gate.status)) continue;
2014
+ const consumedAt = gate.consumed_at ?? new Date().toISOString();
2015
+ const consumed = gate.status === 'resolved' ? { ...gate, status: 'consumed', consumed_at: consumedAt } : gate;
2016
+ if (gate.status === 'resolved') await writeJson(full, consumed);
2017
+ gates.push({
2018
+ gate_id: consumed.gate_id,
2019
+ checkpoint_id: consumed.checkpoint_id,
2020
+ status: consumed.status,
2021
+ secret_received: Boolean(consumed.secret_received),
2022
+ response_sha256: consumed.response_sha256 ?? null,
2023
+ response_ref: consumed.response_ref ? path.join(root, consumed.response_ref) : null,
2024
+ resolved_at: consumed.resolved_at ?? null,
2025
+ consumed_at: consumed.consumed_at ?? null
2026
+ });
2027
+ }
2028
+ await writeJson(contextFile, { version: 1, task_id: taskId, gates });
2029
+ return contextFile;
2030
+ }
2031
+
2032
+ async function destroyConsumedHumanInputSecrets(root, queue, taskId) {
2033
+ const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
2034
+ for (const file of await listJson(gatesDir)) {
2035
+ const full = path.join(gatesDir, file);
2036
+ const gate = await readJson(full);
2037
+ if (gate.task_id !== taskId || gate.status !== 'consumed' || !gate.response_ref) continue;
2038
+ await rm(path.join(root, gate.response_ref), { force: true });
2039
+ await writeJson(full, {
2040
+ ...gate,
2041
+ secret_destroyed: true,
2042
+ secret_destroyed_at: new Date().toISOString()
2043
+ });
2044
+ }
2045
+ }
2046
+
2047
+ async function reconcileHumanInputGates(root, queue, taskId) {
2048
+ const gatesDir = path.join(queueDirFor(root, queue), 'human-input', 'gates');
2049
+ const checkpointsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'checkpoints');
2050
+ const checkpoints = [];
2051
+ for (const file of await listJson(checkpointsDir)) checkpoints.push(await readJson(path.join(checkpointsDir, file)));
2052
+ for (const file of await listJson(gatesDir)) {
2053
+ const full = path.join(gatesDir, file);
2054
+ const gate = await readJson(full);
2055
+ if (gate.task_id !== taskId || gate.status !== 'consumed') continue;
2056
+ const successor = checkpoints
2057
+ .filter((checkpoint) => checkpoint?.revises_checkpoint_id === gate.checkpoint_id)
2058
+ .sort((a, b) => Number(b.sequence ?? 0) - Number(a.sequence ?? 0))[0];
2059
+ if (!successor) continue;
2060
+ await writeJson(full, {
2061
+ ...gate,
2062
+ status: 'satisfied',
2063
+ satisfied_at: new Date().toISOString(),
2064
+ satisfied_by_checkpoint_id: successor.checkpoint_id,
2065
+ successor_status: successor.status ?? null
1901
2066
  });
1902
- await rm(found.file, { force: true });
1903
- requeued = path.relative(root, inboxFile);
1904
2067
  }
1905
- return { gate: resolved, outcome: requeued ? 'resolved_and_requeued' : 'resolved_pending_terminal', requeued };
1906
2068
  }
1907
2069
 
1908
2070
  export function taskRuntimeDirFor(root, queue, taskId) {
@@ -2033,6 +2195,10 @@ function buildTaskContract(queue, task, options = {}) {
2033
2195
  const inferredRisk = inferTaskRisk(task);
2034
2196
  const modelAssessed = task.riskAssessment === 'model_assessed';
2035
2197
  const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
2198
+ const requestText = `${task.title ?? ''}\n${task.body ?? ''}`.toLowerCase();
2199
+ const taskScope = /project[-_ ]level|项目级|完整项目|整体项目|single milestone|单(?:一)?里程碑/.test(requestText)
2200
+ ? 'project'
2201
+ : 'scoped_task';
2036
2202
  return {
2037
2203
  version: 1,
2038
2204
  task_id: task.id,
@@ -2040,6 +2206,7 @@ function buildTaskContract(queue, task, options = {}) {
2040
2206
  title: task.title,
2041
2207
  original_request: task.body,
2042
2208
  goal: task.body,
2209
+ task_scope: taskScope,
2043
2210
  deliverables: [
2044
2211
  'Structured run artifact',
2045
2212
  'Concise completion summary',
@@ -2378,11 +2545,15 @@ function buildDevPlan(contract, acceptancePlan) {
2378
2545
  version: 1,
2379
2546
  task_id: contract.task_id,
2380
2547
  checkpoint_id: firstCheckpointId,
2548
+ milestone_id: firstCheckpointId,
2549
+ revises_checkpoint_id: null,
2381
2550
  status: 'ready_for_acceptance | blocked | needs_human_input',
2382
2551
  summary: 'What changed and why.',
2383
2552
  files_changed: [],
2384
2553
  verification: [],
2385
2554
  blockers: [],
2555
+ deferred_gates: [],
2556
+ project_completion: null,
2386
2557
  risks: [],
2387
2558
  next_action: 'acceptance_review'
2388
2559
  },
@@ -2671,6 +2842,11 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
2671
2842
  await writeJson(reviewFile, review);
2672
2843
  reviews.push({
2673
2844
  checkpointId: review.checkpoint_id,
2845
+ milestoneId: checkpoint.milestone_id ?? null,
2846
+ revisesCheckpointId: checkpoint.revises_checkpoint_id ?? null,
2847
+ sequence: Number(checkpoint.sequence ?? String(checkpoint.checkpoint_id ?? '').match(/(\d+)$/)?.[1] ?? 0),
2848
+ createdAt: checkpoint.created_at ?? review.created_at,
2849
+ projectCompletion: checkpoint.project_completion ?? null,
2674
2850
  status: review.status,
2675
2851
  file: path.relative(root, reviewFile)
2676
2852
  });
@@ -2688,16 +2864,55 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
2688
2864
  };
2689
2865
  }
2690
2866
 
2691
- function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, context = {}) {
2867
+ function compareReviewSequence(a, b) {
2868
+ const sequenceDelta = Number(a.sequence ?? 0) - Number(b.sequence ?? 0);
2869
+ if (sequenceDelta !== 0) return sequenceDelta;
2870
+ return String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
2871
+ }
2872
+
2873
+ function compareReviewRecency(a, b) {
2874
+ const createdDelta = String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? ''));
2875
+ if (createdDelta !== 0 && a.createdAt && b.createdAt) return createdDelta;
2876
+ const aCheckpoint = Number(String(a.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
2877
+ const bCheckpoint = Number(String(b.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
2878
+ if (aCheckpoint !== bCheckpoint) return aCheckpoint - bCheckpoint;
2879
+ return compareReviewSequence(a, b);
2880
+ }
2881
+
2882
+ export function selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews) {
2883
+ const allReviews = acceptanceReviews?.reviews ?? [];
2884
+ const planned = Array.isArray(devPlan?.checkpoints) ? devPlan.checkpoints : [];
2885
+ if (planned.length <= 1) {
2886
+ return [...allReviews].sort(compareReviewRecency).slice(-1);
2887
+ }
2888
+ const selected = [];
2889
+ for (const checkpoint of planned) {
2890
+ const milestoneId = checkpoint.id;
2891
+ const candidates = allReviews.filter((review) =>
2892
+ review.milestoneId === milestoneId || review.checkpointId === milestoneId
2893
+ );
2894
+ if (candidates.length === 0) continue;
2895
+ selected.push([...candidates].sort(compareReviewSequence).at(-1));
2896
+ }
2897
+ return selected;
2898
+ }
2899
+
2900
+ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, context = {}) {
2692
2901
  const reasons = [];
2693
2902
  const nextActions = [];
2694
2903
  const requiredCheckpoints = Array.isArray(devPlan?.checkpoints) ? devPlan.checkpoints.length : 0;
2695
2904
  const checkpointCount = checkpoints?.count ?? 0;
2696
- const reviewCount = acceptanceReviews?.count ?? 0;
2697
- const acceptedCount = acceptanceReviews?.accepted ?? 0;
2698
- const reviseCount = acceptanceReviews?.revise ?? 0;
2699
- const blockedCount = acceptanceReviews?.blocked ?? 0;
2905
+ const allReviews = acceptanceReviews?.reviews ?? [];
2906
+ // Checkpoints produced after the planned set are revision/progress snapshots,
2907
+ // not additional required milestones. Judge the latest complete set so a
2908
+ // resolved historical blocker does not permanently poison the task.
2909
+ const effectiveReviews = selectEffectiveAcceptanceReviews(devPlan, acceptanceReviews);
2910
+ const reviewCount = effectiveReviews.length;
2911
+ const acceptedCount = effectiveReviews.filter((item) => item.status === 'accepted').length;
2912
+ const reviseCount = effectiveReviews.filter((item) => item.status === 'revise').length;
2913
+ const blockedCount = effectiveReviews.filter((item) => item.status === 'blocked').length;
2700
2914
  const dispatchStatus = context.dispatchStatus ?? 'unknown';
2915
+ const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
2701
2916
  let outcome = 'needs_revision';
2702
2917
 
2703
2918
  if (dispatchStatus === 'blocked_preflight') {
@@ -2740,6 +2955,10 @@ function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acc
2740
2955
  outcome = 'needs_revision';
2741
2956
  reasons.push(`Accepted checkpoints (${acceptedCount}) do not cover required checkpoints (${Math.max(requiredCheckpoints, 1)}).`);
2742
2957
  nextActions.push('Complete and review the remaining planned checkpoints.');
2958
+ } else if (contract.task_scope === 'project' && !projectCompletionAccepted) {
2959
+ outcome = 'project_in_progress';
2960
+ reasons.push('The latest milestone is accepted, but the project terminal contract is not accepted.');
2961
+ nextActions.push('Continue with the next safe actionable project backlog item.');
2743
2962
  } else if (contract.requires_human_gate) {
2744
2963
  outcome = 'ready_for_human_review';
2745
2964
  reasons.push('All reviewed checkpoints are accepted, and the task contract requires a human gate.');
@@ -2771,6 +2990,8 @@ function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoints, acc
2771
2990
  planned_checkpoints: requiredCheckpoints,
2772
2991
  produced_checkpoints: checkpointCount,
2773
2992
  reviews: reviewCount,
2993
+ historical_reviews: allReviews.length,
2994
+ effective_review_ids: effectiveReviews.map((review) => review.checkpointId),
2774
2995
  accepted: acceptedCount,
2775
2996
  revise: reviseCount,
2776
2997
  blocked: blockedCount,
@@ -2809,6 +3030,7 @@ function queueStatusFromFinalJudgement(currentStatus, finalJudgement) {
2809
3030
  if (currentStatus !== 'completed') return currentStatus;
2810
3031
  const outcome = finalJudgement.judgement.outcome;
2811
3032
  if (outcome === 'ready_to_apply') return currentStatus;
3033
+ if (outcome === 'project_in_progress') return outcome;
2812
3034
  if (outcome === 'ready_for_human_review') return outcome;
2813
3035
  return outcome;
2814
3036
  }
@@ -2945,6 +3167,7 @@ export async function queueStatus(root, queue) {
2945
3167
  queue,
2946
3168
  queued: (await listJson(queueSubdirFor(root, queue, 'inbox'))).length,
2947
3169
  active: activeFiles.length,
3170
+ waiting: (await listJson(queueSubdirFor(root, queue, 'waiting'))).length,
2948
3171
  done: (await listJson(queueSubdirFor(root, queue, 'done'))).length,
2949
3172
  failed: (await listJson(queueSubdirFor(root, queue, 'failed'))).length,
2950
3173
  canceled: (await listJson(queueSubdirFor(root, queue, 'canceled'))).length,
@@ -3561,7 +3784,7 @@ async function nextQueuedTaskFile(root, queue) {
3561
3784
  return path.join(queueSubdirFor(root, queue, 'inbox'), files[0]);
3562
3785
  }
3563
3786
 
3564
- async function findTaskFile(root, queue, taskId, subdirs = ['inbox', 'active', 'failed', 'done', 'canceled']) {
3787
+ async function findTaskFile(root, queue, taskId, subdirs = ['inbox', 'active', 'waiting', 'failed', 'done', 'canceled']) {
3565
3788
  const wanted = taskId.endsWith('.json') ? taskId : `${taskId}.json`;
3566
3789
  for (const subdir of subdirs) {
3567
3790
  const file = path.join(queueSubdirFor(root, queue, subdir), wanted);
@@ -4528,7 +4751,7 @@ export async function queueRevisionReview(root, queue, options = {}) {
4528
4751
  };
4529
4752
  }
4530
4753
 
4531
- const TASK_STATE_DIRS = ['inbox', 'active', 'failed', 'done', 'canceled'];
4754
+ const TASK_STATE_DIRS = ['inbox', 'active', 'waiting', 'failed', 'done', 'canceled'];
4532
4755
 
4533
4756
  async function listQueueTasks(root, queue) {
4534
4757
  const tasks = [];
@@ -7523,6 +7746,37 @@ async function recoverStaleActive(root, queue, staleActiveMs) {
7523
7746
  return recovered;
7524
7747
  }
7525
7748
 
7749
+ async function recoverOrphanActive(root, queue) {
7750
+ const activeDir = queueSubdirFor(root, queue, 'active');
7751
+ const inboxDir = queueSubdirFor(root, queue, 'inbox');
7752
+ const files = await listJson(activeDir);
7753
+ const recovered = [];
7754
+ for (const file of files) {
7755
+ const activeFile = path.join(activeDir, file);
7756
+ const task = await readJson(activeFile);
7757
+ const recoveredAt = new Date().toISOString();
7758
+ const inboxFile = path.join(inboxDir, file);
7759
+ // Move first so a crash cannot leave duplicate active/inbox copies.
7760
+ await rename(activeFile, inboxFile);
7761
+ await writeJson(inboxFile, {
7762
+ ...task,
7763
+ status: 'queued',
7764
+ orphanRecoveredAt: recoveredAt,
7765
+ orphanRecoveryCount: (task.orphanRecoveryCount ?? 0) + 1,
7766
+ requeuedAt: recoveredAt,
7767
+ requeuedFrom: 'active',
7768
+ recoveryReason: 'queue_lock_reacquired_with_active_task'
7769
+ });
7770
+ recovered.push({
7771
+ taskId: task.id,
7772
+ from: path.relative(root, activeFile),
7773
+ file: path.relative(root, inboxFile),
7774
+ recoveredAt
7775
+ });
7776
+ }
7777
+ return recovered;
7778
+ }
7779
+
7526
7780
  function compactCommandResult(result) {
7527
7781
  return {
7528
7782
  exitCode: result.exitCode,
@@ -7885,6 +8139,18 @@ export async function runQueueOnce(root, options) {
7885
8139
  }
7886
8140
 
7887
8141
  try {
8142
+ // Holding this newly acquired lock proves no previous runner owns a valid
8143
+ // queue lease. Any task left in active/ is therefore an orphan from an
8144
+ // interrupted parent runner. Requeue it immediately so existing
8145
+ // checkpoints can be reviewed and execution can resume in this tick.
8146
+ const orphanRecovered = await recoverOrphanActive(root, queue);
8147
+ if (orphanRecovered.length > 0) {
8148
+ progress.emit('queue', 'orphan_recovered', `Recovered ${orphanRecovered.length} orphan active task(s)`, {
8149
+ queue,
8150
+ count: orphanRecovered.length,
8151
+ tasks: orphanRecovered.map((entry) => entry.taskId)
8152
+ });
8153
+ }
7888
8154
  const staleRecovered = await recoverStaleActive(root, queue, options.staleActiveMs);
7889
8155
  if (staleRecovered.length > 0) {
7890
8156
  progress.emit('queue', 'recovered', `Recovered ${staleRecovered.length} stale active task(s)`, {
@@ -7903,6 +8169,7 @@ export async function runQueueOnce(root, options) {
7903
8169
  status: 'empty',
7904
8170
  exitCode: 0,
7905
8171
  staleRecovered,
8172
+ orphanRecovered,
7906
8173
  progress: progress.events
7907
8174
  };
7908
8175
  }
@@ -7993,8 +8260,13 @@ export async function runQueueOnce(root, options) {
7993
8260
  finalStatus = 'blocked_preflight';
7994
8261
  exitCode = 2;
7995
8262
  } else {
8263
+ const humanInputContextFile = await prepareHumanInputContext(root, queue, task.id);
7996
8264
  const runContext = {
7997
- env: taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
8265
+ env: {
8266
+ ...taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
8267
+ LOOP_HUMAN_INPUT_CONTEXT_FILE: humanInputContextFile,
8268
+ LOOP_HUMAN_INPUT_CONTEXT_FILE_REL: path.relative(root, humanInputContextFile)
8269
+ },
7998
8270
  progress
7999
8271
  };
8000
8272
  if (finalStatus === 'unknown' && worktreeEnabled(options)) {
@@ -8031,6 +8303,7 @@ export async function runQueueOnce(root, options) {
8031
8303
  if (finalStatus === 'unknown') {
8032
8304
  dispatchAttempts = await runDispatchWithRetry(root, options, queue, task, activeFile, runId, timeoutMs, runContext);
8033
8305
  dispatch = dispatchAttempts[dispatchAttempts.length - 1]?.result ?? null;
8306
+ await destroyConsumedHumanInputSecrets(root, queue, task.id);
8034
8307
  const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
8035
8308
  if (dispatch?.canceled) {
8036
8309
  finalStatus = 'superseded';
@@ -8053,6 +8326,7 @@ export async function runQueueOnce(root, options) {
8053
8326
  }
8054
8327
  }
8055
8328
  checkpoints = await checkpointSummary(root, devPlan);
8329
+ await reconcileHumanInputGates(root, queue, task.id);
8056
8330
  progress.emit('acceptance', 'checkpoint_summary', `Collected ${checkpoints.count} checkpoint(s)`, {
8057
8331
  taskId: task.id,
8058
8332
  count: checkpoints.count,
@@ -8131,26 +8405,32 @@ export async function runQueueOnce(root, options) {
8131
8405
  revisionRequest = await writeRevisionRequest(root, queue, task, taskContract, acceptancePlan, devPlan, finalJudgement, acceptanceReviews);
8132
8406
  }
8133
8407
 
8134
- destination = finalStatus === 'completed'
8408
+ destination = finalStatus === 'project_in_progress'
8409
+ ? queueSubdirFor(root, queue, 'inbox')
8410
+ : finalStatus === 'completed'
8135
8411
  ? queueSubdirFor(root, queue, 'done')
8136
8412
  : finalStatus === 'superseded' ? queueSubdirFor(root, queue, 'canceled')
8137
8413
  : queueSubdirFor(root, queue, 'failed');
8138
- exitCode = ['completed', 'superseded'].includes(finalStatus) ? 0 : 1;
8414
+ exitCode = ['completed', 'superseded', 'project_in_progress'].includes(finalStatus) ? 0 : 1;
8139
8415
 
8140
8416
  const finishedAt = new Date().toISOString();
8141
- progress.emit('queue', finalStatus === 'completed' ? 'completed' : 'needs_attention', `Task finished with status ${finalStatus}`, {
8417
+ progress.emit('queue', finalStatus === 'completed' ? 'completed' : finalStatus === 'project_in_progress' ? 'continued' : 'needs_attention', `Task finished with status ${finalStatus}`, {
8142
8418
  queue,
8143
8419
  taskId: task.id,
8144
8420
  status: finalStatus
8145
8421
  });
8146
8422
  const completedTask = {
8147
8423
  ...task,
8148
- status: finalStatus,
8424
+ status: finalStatus === 'project_in_progress' ? 'queued' : finalStatus,
8149
8425
  startedAt,
8150
8426
  finishedAt,
8151
8427
  attempts: (task.attempts ?? 0) + Math.max(dispatchAttempts.length, dispatch ? 1 : 0),
8152
8428
  runPath: path.relative(root, runPath)
8153
8429
  };
8430
+ if (finalStatus === 'project_in_progress') {
8431
+ completedTask.projectContinuedAt = finishedAt;
8432
+ completedTask.projectContinuationCount = (task.projectContinuationCount ?? 0) + 1;
8433
+ }
8154
8434
  const completedFile = path.join(destination, path.basename(activeFile));
8155
8435
  await writeJson(completedFile, completedTask);
8156
8436
  await rm(activeFile, { force: true });
@@ -8224,6 +8504,7 @@ export async function runQueueOnce(root, options) {
8224
8504
  } : null,
8225
8505
  verification,
8226
8506
  staleRecovered,
8507
+ orphanRecovered,
8227
8508
  progress: progress.events,
8228
8509
  taskPath: path.relative(root, completedFile),
8229
8510
  runPath: path.relative(root, runPath)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.7.2",
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 --root . --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",
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 {
@@ -78,6 +78,48 @@ const contract = await readJson(path.join(taskRuntimeDirFor(root, queue, routed.
78
78
  assert.equal(contract.risk_level, 'model_assessed');
79
79
  assert.equal(contract.requires_human_gate, false);
80
80
 
81
+ const orphanQueue = 'orphan-recovery-smoke';
82
+ const orphanRouted = await routeLoopMessage(root, {
83
+ route: true,
84
+ confirmExecute: true,
85
+ queue: orphanQueue,
86
+ message: '走 loop 验证异常退出自动恢复',
87
+ sourceChannel: 'feishu',
88
+ sourceTarget: 'user-1'
89
+ });
90
+ const orphanName = `${orphanRouted.task.id}.json`;
91
+ const orphanInbox = path.join(queueSubdirFor(root, orphanQueue, 'inbox'), orphanName);
92
+ const orphanActive = path.join(queueSubdirFor(root, orphanQueue, 'active'), orphanName);
93
+ await writeJson(orphanActive, { ...(await readJson(orphanInbox)), status: 'active', startedAt: new Date().toISOString() });
94
+ await rm(orphanInbox, { force: true });
95
+ await writeJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'), {
96
+ version: 1,
97
+ task_id: orphanRouted.task.id,
98
+ checkpoint_id: 'cp-before-crash',
99
+ status: 'in_progress',
100
+ summary: 'Durable progress written before the parent runner exited.',
101
+ files_changed: [],
102
+ verification: [],
103
+ blockers: [],
104
+ risks: [],
105
+ next_action: 'resume_from_checkpoint'
106
+ });
107
+ const orphanRun = await runQueueOnce(root, {
108
+ queue: orphanQueue,
109
+ dispatcher: '/bin/true',
110
+ timeoutMs: 10_000,
111
+ leaseMs: 20_000,
112
+ staleActiveMs: 60_000
113
+ });
114
+ assert.equal(orphanRun.processed, true);
115
+ assert.equal(orphanRun.run.orphanRecovered.length, 1);
116
+ assert.equal(orphanRun.run.orphanRecovered[0].taskId, orphanRouted.task.id);
117
+ assert.ok(orphanRun.progress.some((event) => event.status === 'orphan_recovered'));
118
+ const orphanTerminal = await readJson(path.join(root, orphanRun.taskPath));
119
+ assert.equal(orphanTerminal.orphanRecoveryCount, 1);
120
+ assert.equal(orphanTerminal.requeuedFrom, 'active');
121
+ assert.equal((await readJson(path.join(taskRuntimeDirFor(root, orphanQueue, orphanRouted.task.id), 'checkpoints', 'cp-before-crash.json'))).checkpoint_id, 'cp-before-crash');
122
+
81
123
  const reviewQueue = 'human-review-smoke';
82
124
  const reviewTask = await routeLoopMessage(root, {
83
125
  route: true,
@@ -332,10 +374,45 @@ const gateId = gateSent.results[0].gateId;
332
374
  const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1' });
333
375
  assert.equal(resolved.outcome, 'resolved_and_requeued');
334
376
  const requeuedTask = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
335
- assert.equal(requeuedTask.humanInput.response, '123456');
336
- assert.match(requeuedTask.body, /Human input for gate/);
377
+ assert.equal(requeuedTask.humanInput.secret_received, true);
378
+ assert.equal(requeuedTask.body.includes('123456'), false);
379
+ const resolvedGate = await readJson(path.join(root, resolved.ledger ?? gateSent.results[0].ledger));
380
+ assert.equal(JSON.stringify(resolvedGate).includes('123456'), false);
381
+ assert.equal(resolvedGate.response_sha256.length, 64);
382
+
383
+ // Queued tasks receive a non-sensitive event reference without being moved.
384
+ const queuedCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-queued.json');
385
+ await writeJson(queuedCheckpoint, {
386
+ version: 1, task_id: routed.task.id, checkpoint_id: 'cp-queued', status: 'needs_human_input',
387
+ blockers: ['Provide queued input.'], verification: [], risks: [], next_action: 'wait'
388
+ });
389
+ await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
390
+ const queuedGateId = `${routed.task.id}:cp-queued`;
391
+ const waitingTaskFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(failedFile));
392
+ assert.equal((await readJson(waitingTaskFile)).status, 'waiting_for_human');
393
+ const queuedResolved = await resolveHumanInput(root, { queue, gateId: queuedGateId, input: 'queued-secret' });
394
+ assert.equal(queuedResolved.outcome, 'resolved_and_requeued');
395
+ const queuedAfterInput = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
396
+ assert.equal(queuedAfterInput.status, 'queued');
397
+ assert.equal(JSON.stringify(queuedAfterInput).includes('queued-secret'), false);
398
+
399
+ // Active tasks are not mutated; their event is consumed at the next safe tick.
400
+ const inboxRequeued = path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile));
401
+ const activeRequeued = path.join(queueSubdirFor(root, queue, 'active'), path.basename(failedFile));
402
+ await rename(inboxRequeued, activeRequeued);
403
+ const activeBefore = await readJson(activeRequeued);
404
+ const activeCheckpoint = path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-active.json');
405
+ await writeJson(activeCheckpoint, {
406
+ version: 1, task_id: routed.task.id, checkpoint_id: 'cp-active', status: 'needs_human_input',
407
+ blockers: ['Provide active input.'], verification: [], risks: [], next_action: 'wait'
408
+ });
409
+ await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
410
+ const activeResolved = await resolveHumanInput(root, { queue, gateId: `${routed.task.id}:cp-active`, input: 'active-secret' });
411
+ assert.equal(activeResolved.outcome, 'resolved_pending_safe_boundary');
412
+ assert.deepEqual(await readJson(activeRequeued), activeBefore);
413
+ await rename(activeRequeued, inboxRequeued);
337
414
  await writeJson(failedFile, { ...requeuedTask, status: 'needs_human_input' });
338
- await rm(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)), { force: true });
415
+ await rm(inboxRequeued, { force: true });
339
416
 
340
417
  const dryRun = await notifyTerminalTasks(root, { queue, dryRun: true });
341
418
  assert.equal(dryRun.results[0].outcome, 'dry_run');
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp, mkdir, rm } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ doctorReport,
8
+ enqueueTask,
9
+ queueSchedulerTick,
10
+ writeJson
11
+ } from '../lib/core.mjs';
12
+
13
+ const root = await mkdtemp(path.join(tmpdir(), 'loop-scheduler-heartbeat-'));
14
+ const queue = 'required-scheduler';
15
+ await mkdir(path.join(root, 'configs', 'loops', 'queues'), { recursive: true });
16
+ await writeJson(path.join(root, 'configs', 'loops', 'queues', `${queue}.json`), {
17
+ queue,
18
+ dispatcher: '/bin/true',
19
+ scheduler: {
20
+ required: true,
21
+ heartbeatMaxAge: '5m',
22
+ initialInterval: '1m',
23
+ minInterval: '1m',
24
+ maxInterval: '4h'
25
+ }
26
+ });
27
+ await enqueueTask(root, { queue, title: 'heartbeat smoke', task: 'remain queued' });
28
+
29
+ const missing = await doctorReport(root);
30
+ const missingCheck = missing.checks.find((check) => check.id === `queue:${queue}:scheduler-heartbeat`);
31
+ assert.equal(missingCheck?.ok, false);
32
+ assert.equal(missingCheck?.detail?.code, 'scheduler_missing');
33
+
34
+ await queueSchedulerTick(root, {
35
+ queue,
36
+ scheduler: { initialInterval: '1m', minInterval: '1m', maxInterval: '4h' },
37
+ planOnly: true,
38
+ forceDue: true
39
+ });
40
+ const healthy = await doctorReport(root);
41
+ const healthyCheck = healthy.checks.find((check) => check.id === `queue:${queue}:scheduler-heartbeat`);
42
+ assert.equal(healthyCheck?.ok, true);
43
+ assert.equal(healthyCheck?.detail?.code, 'scheduler_healthy');
44
+
45
+ await rm(root, { recursive: true, force: true });
46
+ console.log('scheduler heartbeat self-test passed');
@@ -292,6 +292,8 @@ Summaries must cite the latest run/task/project evidence, verification performed
292
292
 
293
293
  Use scheduler ticks only after manual verification. Adaptive schedules may speed up with successful queued work and back off on empty queues, failures, long runs, or human gates.
294
294
 
295
+ `queue-scheduler-tick` is adaptive cadence logic, not a resident daemon. A cron, systemd timer, or equivalent external scheduler must wake it regularly. For project queues that promise automatic continuation, set `scheduler.required=true` and a bounded `scheduler.heartbeatMaxAge`; `doctor` must fail with `scheduler_missing` whenever queued work exists without a fresh scheduler heartbeat.
296
+
295
297
  Progress notification must be scoped and idempotent. Report failures, human gates, status changes, and terminal completion promptly; throttle routine progress and idle updates.
296
298
 
297
299
  ## Final Checklist