taskforce-loop-engineering 0.8.3 → 0.8.5

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