taskforce-loop-engineering 0.15.3 → 0.15.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
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.15.5 - 2026-08-24
6
+
7
+ - Treat embedded-runtime `incomplete_turn` / abandoned-liveness trailers as recoverable interruptions even when the dispatcher wrapper exits zero, preventing partial work from being accepted or stale human gates from being replayed.
8
+ - Keep future paid, deployment, and publication authorization boundaries deferred while an unrelated safe project backlog item remains actionable.
9
+ - Reconcile an already-materialized premature deferred gate by superseding it and restoring the project carrier to the runnable queue.
10
+ - Resolve the authoritative project from checkpoint milestones when a task still carries a broader parent project id.
11
+
12
+ ## 0.15.4 - 2026-08-22
13
+
14
+ - Preserve source conversation and project metadata across revision planning, direct revision enqueue, and saved-plan application.
15
+ - Refuse to create an unroutable revision when a routed source task has lost its required channel or target.
16
+ - Materialize a human-input gate for the newest authoritative project carrier when it finishes blocked, while continuing to suppress historical failed-task replay.
17
+ - Add regression coverage for routed revision inheritance and blocked project gate delivery.
18
+
5
19
  ## 0.15.3 - 2026-08-22
6
20
 
7
21
  - Remove workspace-specific Ironman queue and dispatcher instructions from the distributed skill.
package/lib/core.mjs CHANGED
@@ -1266,17 +1266,32 @@ export async function reconcileProjectGates(root, options = {}) {
1266
1266
  gate = { ...gate, ...await gateProjectMetadata(root, task, checkpoint) };
1267
1267
  await writeJson(full, gate);
1268
1268
  }
1269
- const spec = specs.get(gate.project_id);
1269
+ const spec = await projectSpecForCheckpoint(root, [...specs.values()], gate.project_id, checkpoint);
1270
+ if (spec?.project && gate.project_id !== spec.project) {
1271
+ gate = { ...gate, project_id: spec.project };
1272
+ await writeJson(full, gate);
1273
+ }
1274
+ const safeBacklogActionable = gate.gate_kind === 'deferred_authorization'
1275
+ && await projectHasSafeActionableBacklog(root, spec);
1270
1276
  const invalid = gate.gate_kind === 'deferred_authorization' && await projectTerminalAccepted(root, spec)
1271
1277
  ? { code: 'project_accepted_optional_deferred', status: 'superseded' }
1272
- : gateInvalidity(gate, spec);
1278
+ : safeBacklogActionable
1279
+ ? { code: 'safe_backlog_actionable', status: 'superseded' }
1280
+ : gateInvalidity(gate, spec);
1273
1281
  if (!invalid) continue;
1274
1282
  const now = new Date().toISOString();
1275
1283
  const reconciled = { ...gate, status: invalid.status, reconciliation: { ...invalid, reconciled_at: now } };
1276
1284
  await writeJson(full, reconciled);
1277
1285
  if (found?.subdir === 'waiting' && task?.waitingGateId === gate.gate_id) {
1278
- const canceledFile = path.join(queueSubdirFor(root, queue, 'canceled'), path.basename(found.file));
1279
- await writeJson(canceledFile, { ...task, status: invalid.status, canceledAt: now, gateReconciliation: reconciled.reconciliation });
1286
+ const resumeSafeBacklog = invalid.code === 'safe_backlog_actionable';
1287
+ const destinationFile = path.join(queueSubdirFor(root, queue, resumeSafeBacklog ? 'inbox' : 'canceled'), path.basename(found.file));
1288
+ const restored = { ...task, status: resumeSafeBacklog ? 'queued' : invalid.status, gateReconciliation: reconciled.reconciliation };
1289
+ delete restored.waitingGateId;
1290
+ delete restored.waitKind;
1291
+ delete restored.waitingSince;
1292
+ if (!resumeSafeBacklog) restored.canceledAt = now;
1293
+ else restored.requeuedAt = now;
1294
+ await writeJson(destinationFile, restored);
1280
1295
  await rm(found.file, { force: true });
1281
1296
  }
1282
1297
  results.push({ queue, gateId: gate.gate_id, projectId: gate.project_id, outcome: invalid.status, reason: invalid.code });
@@ -1883,6 +1898,25 @@ function taskSourceFromOptions(options = {}) {
1883
1898
  return Object.values(source).some(Boolean) ? source : null;
1884
1899
  }
1885
1900
 
1901
+ function taskSourceOptions(source) {
1902
+ if (!source) return {};
1903
+ return {
1904
+ sourceChannel: source.channel ?? undefined,
1905
+ sourceTarget: source.target ?? undefined,
1906
+ sourceAccount: source.account ?? undefined,
1907
+ sourceMessageId: source.message_id ?? undefined,
1908
+ sourceReplyTo: source.reply_to ?? undefined
1909
+ };
1910
+ }
1911
+
1912
+ function assertRevisionRoutingSource(task) {
1913
+ const explicitlyRouted = String(task?.body ?? '').startsWith('This task was explicitly routed to Loop Engineering from a source conversation.');
1914
+ if (!task?.source && !explicitlyRouted) return;
1915
+ if (!task?.source?.channel || !task?.source?.target) {
1916
+ throw new Error(`Routed revision source task ${task?.id ?? 'unknown'} is missing source.channel/source.target; refusing to create an unroutable revision.`);
1917
+ }
1918
+ }
1919
+
1886
1920
  export function classifyLoopMessage(message) {
1887
1921
  const text = String(message ?? '').trim();
1888
1922
  if (!text) throw new Error('route-message requires --message.');
@@ -2380,6 +2414,43 @@ function materializableDeferredGates(checkpoint) {
2380
2414
  });
2381
2415
  }
2382
2416
 
2417
+ async function projectBacklogItems(root, spec) {
2418
+ if (!spec) return [];
2419
+ const configured = spec.backlogSource ?? spec.authoritativeBacklog;
2420
+ if (!configured) return [];
2421
+ let loaded;
2422
+ try {
2423
+ loaded = await readJson(path.resolve(root, safeRelativePath(configured, 'project backlog source')));
2424
+ } catch {
2425
+ return [];
2426
+ }
2427
+ return Array.isArray(loaded.items) ? loaded.items : Array.isArray(loaded.tasks) ? loaded.tasks : [];
2428
+ }
2429
+
2430
+ async function projectSpecForCheckpoint(root, specs, projectId, checkpoint) {
2431
+ const direct = specs.find((item) => item.project === projectId);
2432
+ const milestoneId = checkpoint?.milestone_id;
2433
+ if (!milestoneId) return direct;
2434
+ if ((await projectBacklogItems(root, direct)).some((item) => item.id === milestoneId)) return direct;
2435
+ for (const spec of specs) {
2436
+ if ((await projectBacklogItems(root, spec)).some((item) => item.id === milestoneId)) return spec;
2437
+ }
2438
+ return direct;
2439
+ }
2440
+
2441
+ async function projectHasSafeActionableBacklog(root, spec) {
2442
+ const items = await projectBacklogItems(root, spec);
2443
+ const byId = new Map(items.map((item) => [item.id, item]));
2444
+ const complete = new Set(['accepted', 'complete', 'completed', 'done', 'phase_complete']);
2445
+ const runnable = new Set(['pending', 'queued', 'ready', 'in_progress', 'active']);
2446
+ return items.some((item) => {
2447
+ if (!item?.id || !runnable.has(item.status)) return false;
2448
+ if (item.requires_human_input === true || item.blocked === true || item.status === 'blocked') return false;
2449
+ const dependencies = Array.isArray(item.dependsOn) ? item.dependsOn : Array.isArray(item.depends_on) ? item.depends_on : [];
2450
+ return dependencies.every((id) => complete.has(byId.get(id)?.status));
2451
+ });
2452
+ }
2453
+
2383
2454
  async function tasksById(root, queue) {
2384
2455
  const tasks = new Map();
2385
2456
  for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
@@ -2433,9 +2504,14 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2433
2504
  const deferredOnly = latestDeferredGates.length > 0
2434
2505
  && (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
2435
2506
  && !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
2436
- const spec = specs.find((item) => item.project === metadata.project_id);
2507
+ const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
2437
2508
  if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
2438
- candidates.push({ taskId, entry, projectId: metadata.project_id, ...checkpoints[0] });
2509
+ // deferred_gates describe later authority boundaries. They become a
2510
+ // waiting gate only after the authoritative backlog has no unrelated safe
2511
+ // item left to run. Otherwise a future paid/deploy/publish boundary would
2512
+ // incorrectly stop local project development.
2513
+ if (deferredOnly && await projectHasSafeActionableBacklog(root, spec)) continue;
2514
+ candidates.push({ taskId, entry, projectId: spec?.project ?? metadata.project_id, ...checkpoints[0] });
2439
2515
  }
2440
2516
 
2441
2517
  // A project deferred gate is materialized only from its newest authoritative
@@ -2446,7 +2522,12 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2446
2522
  if (!current || taskRecency(candidate.entry.task) > taskRecency(current.entry.task)) newestByProject.set(candidate.projectId, candidate);
2447
2523
  }
2448
2524
  return candidates.filter((candidate) => {
2449
- if (candidate.projectId) return newestByProject.get(candidate.projectId) === candidate && candidate.entry.subdir !== 'failed';
2525
+ if (candidate.projectId) {
2526
+ const authoritative = newestByProject.get(candidate.projectId) === candidate;
2527
+ const actionableFailed = candidate.entry.subdir === 'failed'
2528
+ && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status);
2529
+ return authoritative && (candidate.entry.subdir !== 'failed' || actionableFailed);
2530
+ }
2450
2531
  return ['inbox', 'active', 'waiting'].includes(candidate.entry.subdir)
2451
2532
  || (candidate.entry.subdir === 'failed' && ['needs_human_input', 'blocked'].includes(candidate.checkpoint.status));
2452
2533
  });
@@ -5232,6 +5313,7 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
5232
5313
  const found = await findTaskFile(root, normalized, taskId, ['failed', 'done']);
5233
5314
  if (!found) throw new Error(`Task not found for revision: ${taskId}`);
5234
5315
  const task = await readJson(found.file);
5316
+ assertRevisionRoutingSource(task);
5235
5317
  if (!task.runPath) throw new Error(`Task has no runPath for revision: ${task.id}`);
5236
5318
  const run = await readJson(path.join(root, safeRelativePath(task.runPath, 'task runPath')));
5237
5319
  const humanDecision = await latestHumanDecision(root, normalized, task.id);
@@ -5255,6 +5337,8 @@ export async function queueRevisionPlan(root, queue, taskId, options = {}) {
5255
5337
  const plannedTask = {
5256
5338
  title,
5257
5339
  body,
5340
+ source: task.source ?? null,
5341
+ projectId: task.projectId ?? task.project_id ?? null,
5258
5342
  revisionOf: task.id,
5259
5343
  revisionSourceRun: task.runPath,
5260
5344
  revisionRequestPath,
@@ -5288,7 +5372,9 @@ export async function queueRevisionNext(root, queue, taskId, options = {}) {
5288
5372
  const enqueued = await enqueueTask(root, {
5289
5373
  queue: plan.queue,
5290
5374
  title: plan.plannedTask.title,
5291
- task: plan.plannedTask.body
5375
+ task: plan.plannedTask.body,
5376
+ projectId: plan.plannedTask.projectId ?? undefined,
5377
+ ...taskSourceOptions(plan.plannedTask.source)
5292
5378
  });
5293
5379
  const nextTask = {
5294
5380
  ...enqueued.task,
@@ -5340,7 +5426,9 @@ export async function queueRevisionApplyPlan(root, planPath, options = {}) {
5340
5426
  const enqueued = await enqueueTask(root, {
5341
5427
  queue: normalized,
5342
5428
  title: plan.plannedTask.title,
5343
- task: plan.plannedTask.body
5429
+ task: plan.plannedTask.body,
5430
+ projectId: plan.plannedTask.projectId ?? undefined,
5431
+ ...taskSourceOptions(plan.plannedTask.source)
5344
5432
  });
5345
5433
  const guard = plan.revisionPolicyGuard ?? plan.plannedTask.revisionPolicyGuard ?? null;
5346
5434
  const nextTask = {
@@ -8700,6 +8788,9 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
8700
8788
  ];
8701
8789
 
8702
8790
  const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
8791
+ { category: 'incomplete_turn', pattern: '"kind": "incomplete_turn"' },
8792
+ { category: 'incomplete_turn', pattern: '"livenessState": "abandoned"' },
8793
+ { category: 'incomplete_turn', pattern: 'stopped before confirming the turn was complete' },
8703
8794
  { category: 'compaction_timeout', pattern: 'compaction timed out' },
8704
8795
  { category: 'compaction_timeout', pattern: 'transcript compaction failed' },
8705
8796
  { category: 'transport_error', pattern: 'connection reset' },
@@ -8709,7 +8800,28 @@ const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
8709
8800
  ];
8710
8801
 
8711
8802
  export function dispatchFailureClassification(result, retry = {}) {
8712
- if (!result || result.exitCode === 0) {
8803
+ if (!result) {
8804
+ return {
8805
+ category: 'ok',
8806
+ requiresHumanAction: false,
8807
+ matchedPattern: null
8808
+ };
8809
+ }
8810
+ // Some embedded runtimes exit their wrapper successfully after the model
8811
+ // turn itself was abandoned. Inspect the structured trailer before trusting
8812
+ // exitCode=0 or the queue may accept partial work and replay a stale gate.
8813
+ const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
8814
+ const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
8815
+ const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
8816
+ if (runtimeMatch) {
8817
+ return {
8818
+ category: runtimeMatch.category ?? 'runtime_interruption',
8819
+ requiresHumanAction: false,
8820
+ recoverableRuntime: true,
8821
+ matchedPattern: runtimeMatch.pattern ?? runtimeMatch
8822
+ };
8823
+ }
8824
+ if (result.exitCode === 0) {
8713
8825
  return {
8714
8826
  category: 'ok',
8715
8827
  requiresHumanAction: false,
@@ -8724,7 +8836,6 @@ export function dispatchFailureClassification(result, retry = {}) {
8724
8836
  matchedPattern: null
8725
8837
  };
8726
8838
  }
8727
- const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
8728
8839
  const patterns = retry.requiresHumanActionPatterns ?? DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS;
8729
8840
  const matched = patterns.find((pattern) => output.includes(pattern.toLowerCase()));
8730
8841
  if (matched) {
@@ -8735,16 +8846,6 @@ export function dispatchFailureClassification(result, retry = {}) {
8735
8846
  matchedPattern: matched
8736
8847
  };
8737
8848
  }
8738
- const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
8739
- const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
8740
- if (runtimeMatch) {
8741
- return {
8742
- category: runtimeMatch.category ?? 'runtime_interruption',
8743
- requiresHumanAction: false,
8744
- recoverableRuntime: true,
8745
- matchedPattern: runtimeMatch.pattern ?? runtimeMatch
8746
- };
8747
- }
8748
8849
  return {
8749
8850
  category: 'retryable_failure',
8750
8851
  requiresHumanAction: false,
@@ -8877,9 +8978,11 @@ async function runDispatchWithRetry(root, options, queue, task, activeFile, runI
8877
8978
  result,
8878
8979
  failureClassification
8879
8980
  });
8880
- const dispatchStatus = result.exitCode === 0
8881
- ? 'passed'
8882
- : failureClassification.requiresHumanAction ? 'needs_human_action' : 'failed';
8981
+ const dispatchStatus = failureClassification.recoverableRuntime
8982
+ ? 'interrupted'
8983
+ : result.exitCode === 0
8984
+ ? 'passed'
8985
+ : failureClassification.requiresHumanAction ? 'needs_human_action' : 'failed';
8883
8986
  runContext.progress?.emit('dispatch', dispatchStatus, `Dispatcher attempt ${attempt} exited ${result.exitCode}`, {
8884
8987
  attempt,
8885
8988
  exitCode: result.exitCode,
@@ -9206,6 +9309,10 @@ export async function runQueueOnce(root, options) {
9206
9309
  const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
9207
9310
  if (dispatch?.canceled) {
9208
9311
  finalStatus = 'superseded';
9312
+ } else if (dispatchClassification?.recoverableRuntime) {
9313
+ const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
9314
+ const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
9315
+ finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
9209
9316
  } else if (dispatch?.exitCode === 0 && worktreeEnabled(options)) {
9210
9317
  verification = await runVerifyCommands(options.worktree?.verifyCommands ?? [], worktree.path, timeoutMs, progress);
9211
9318
  const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
@@ -9215,10 +9322,6 @@ export async function runQueueOnce(root, options) {
9215
9322
  finalStatus = 'completed';
9216
9323
  } else if (dispatchClassification?.requiresHumanAction) {
9217
9324
  finalStatus = 'needs_human_input';
9218
- } else if (dispatchClassification?.recoverableRuntime) {
9219
- const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
9220
- const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
9221
- finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
9222
9325
  } else {
9223
9326
  finalStatus = 'failed';
9224
9327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.3",
3
+ "version": "0.15.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",
@@ -47,6 +47,36 @@ assert.equal(inferTaskScope({ body: 'Continue the overall project with a product
47
47
  assert.equal(classification.recoverableRuntime, true);
48
48
  }
49
49
 
50
+ {
51
+ const classification = dispatchFailureClassification({
52
+ exitCode: 0,
53
+ timedOut: false,
54
+ stderr: '',
55
+ stdout: JSON.stringify({
56
+ replayInvalid: true,
57
+ livenessState: 'abandoned',
58
+ error: {
59
+ kind: 'incomplete_turn',
60
+ message: 'Codex stopped before confirming the turn was complete. Some work may already have been performed; verify the current state before retrying.'
61
+ }
62
+ }, null, 2)
63
+ });
64
+ assert.equal(classification.category, 'incomplete_turn');
65
+ assert.equal(classification.recoverableRuntime, true);
66
+ assert.equal(classification.requiresHumanAction, false);
67
+ }
68
+
69
+ {
70
+ const classification = dispatchFailureClassification({
71
+ exitCode: 0,
72
+ timedOut: false,
73
+ stderr: '',
74
+ stdout: JSON.stringify({ livenessState: 'working', completion: { stopReason: 'stop' } })
75
+ });
76
+ assert.equal(classification.category, 'ok');
77
+ assert.equal(classification.recoverableRuntime, undefined);
78
+ }
79
+
50
80
  {
51
81
  const devPlan = { checkpoints: [{ id: 'cp1' }] };
52
82
  const reviews = { reviews: [{ checkpointId: 'cp38', sequence: 38, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
@@ -111,6 +111,28 @@ assert.equal(deferredGate.authorization_requirements[0].action, 'production_roll
111
111
  assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
112
112
  assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
113
113
 
114
+ // A future authorization must not stop the project while an unrelated safe
115
+ // backlog item remains actionable.
116
+ await reconcileProjectGates(root, { queue });
117
+ await writeFile(authoritativeBacklog, `${JSON.stringify({
118
+ status: 'ongoing',
119
+ items: [
120
+ { id: 'LOCAL-01', status: 'phase_complete', dependsOn: [] },
121
+ { id: 'LOCAL-02', status: 'pending', dependsOn: ['LOCAL-01'] }
122
+ ]
123
+ }, null, 2)}\n`);
124
+ const futureGateTask = await enqueueTask(root, { queue, title: 'OpenReel future production gate', task: 'Continue safe local backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
125
+ const futureGateDir = path.join(taskRuntimeDirFor(root, queue, futureGateTask.task.id), 'checkpoints');
126
+ await mkdir(futureGateDir, { recursive: true });
127
+ await writeFile(path.join(futureGateDir, 'cp1.json'), `${JSON.stringify({
128
+ version: 1, task_id: futureGateTask.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
129
+ project_completion: { status: 'in_progress' }, next_action: 'Implement LOCAL-02 locally.',
130
+ deferred_gates: [{ id: 'production-later', action: 'production_deploy', required_authority: 'Owner authorization after local candidate acceptance.' }]
131
+ }, null, 2)}\n`);
132
+ const futureNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
133
+ assert.equal(futureNotice.results.some((item) => item.taskId === futureGateTask.task.id), false);
134
+ assert.equal((await queueStatus(root, queue)).waiting, 0);
135
+
114
136
  // Conditional policy boundaries are not current blockers. Plain prose in
115
137
  // deferred_gates must not materialize a gate without a concrete action and
116
138
  // authority requirement.
@@ -145,4 +167,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
145
167
  assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
146
168
  assert.equal((await queueStatus(root, queue)).waiting, 0);
147
169
 
148
- console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
170
+ console.log(JSON.stringify({ status: 'ok', assertions: ['standing project authorization reaches task contract', 'structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'future gate does not stop safe actionable backlog', 'conditional policy prose does not create a gate', 'accepted project optional deferred gate stays out of queue'] }));
@@ -11,6 +11,7 @@ import {
11
11
  notifyHumanInputRequests,
12
12
  notifyTerminalTasks,
13
13
  queueHumanDecision,
14
+ queueRevisionNext,
14
15
  queueDirFor,
15
16
  queueStatus,
16
17
  queueSubdirFor,
@@ -562,5 +563,96 @@ assert.equal((await readdir(queueSubdirFor(regressionRoot, regressionQueue, 'don
562
563
  const regressionRepeated = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
563
564
  assert.equal(regressionRepeated.sent, 0);
564
565
 
566
+ // The newest authoritative project carrier may itself finish blocked. It must
567
+ // become a durable waiting gate instead of being discarded with historical
568
+ // failed attempts.
569
+ const blockedProjectTask = 'latest-blocked';
570
+ await writeJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'failed'), `${blockedProjectTask}.json`), {
571
+ id: blockedProjectTask,
572
+ title: 'demo latest blocked',
573
+ body: 'demo R-1',
574
+ projectId: 'demo',
575
+ status: 'blocked',
576
+ enqueuedAt: '2026-01-03T00:00:00Z',
577
+ source: { channel: 'feishu', target: 'owner' }
578
+ });
579
+ await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'checkpoints', 'cp2.json'), {
580
+ version: 1,
581
+ task_id: blockedProjectTask,
582
+ checkpoint_id: 'cp2',
583
+ milestone_id: 'R-1',
584
+ sequence: 2,
585
+ status: 'needs_human_input',
586
+ blockers: ['Approve the production activation.'],
587
+ verification: [],
588
+ risks: [],
589
+ project_completion: 'in_progress',
590
+ next_action: 'wait'
591
+ });
592
+ await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, blockedProjectTask), 'final_judgement.json'), {
593
+ version: 1,
594
+ task_id: blockedProjectTask,
595
+ outcome: 'blocked',
596
+ coverage: { effective_review_ids: ['cp2'] }
597
+ });
598
+ const blockedProjectGate = await notifyHumanInputRequests(regressionRoot, { queue: regressionQueue, notifyCommand: '/bin/true' });
599
+ assert.equal(blockedProjectGate.sent, 1);
600
+ assert.equal((await readJson(path.join(queueSubdirFor(regressionRoot, regressionQueue, 'waiting'), `${blockedProjectTask}.json`))).status, 'waiting_for_human');
601
+
602
+ // A routed revision must preserve the complete source envelope so a later
603
+ // human gate or terminal result returns to the originating conversation.
604
+ const revisionQueue = 'revision-routing';
605
+ const revisionSource = await routeLoopMessage(root, {
606
+ route: true,
607
+ confirmExecute: true,
608
+ supersedeActive: true,
609
+ queue: revisionQueue,
610
+ message: '走 loop fix revision routing',
611
+ sourceChannel: 'feishu',
612
+ sourceTarget: 'user-revision',
613
+ sourceAccount: 'main',
614
+ sourceMessageId: 'revision-source-message',
615
+ sourceReplyTo: 'revision-source-reply'
616
+ });
617
+ const revisionSourceFile = path.join(queueSubdirFor(root, revisionQueue, 'inbox'), `${revisionSource.task.id}.json`);
618
+ const revisionFailedFile = path.join(queueSubdirFor(root, revisionQueue, 'failed'), `${revisionSource.task.id}.json`);
619
+ const revisionRequestFile = path.join(taskRuntimeDirFor(root, revisionQueue, revisionSource.task.id), 'revision_request.json');
620
+ const revisionRunFile = path.join(queueSubdirFor(root, revisionQueue, 'runs'), `${revisionSource.task.id}.json`);
621
+ await writeJson(revisionRequestFile, {
622
+ version: 1,
623
+ task_id: revisionSource.task.id,
624
+ status: 'requested',
625
+ revision_goals: [{ check: 'routing', required_change: 'Preserve source routing.' }],
626
+ next_checkpoint: { suggested_id: 'cp2' }
627
+ });
628
+ await writeJson(revisionRunFile, {
629
+ version: 2,
630
+ taskId: revisionSource.task.id,
631
+ status: 'failed',
632
+ finalJudgement: { outcome: 'needs_revision' },
633
+ revisionRequest: { path: path.relative(root, revisionRequestFile) }
634
+ });
635
+ await writeJson(revisionFailedFile, {
636
+ ...revisionSource.task,
637
+ status: 'failed',
638
+ projectId: 'demo-project',
639
+ runPath: path.relative(root, revisionRunFile)
640
+ });
641
+ await rm(revisionSourceFile, { force: true });
642
+ const revision = await queueRevisionNext(root, revisionQueue, revisionSource.task.id, {
643
+ force: true,
644
+ strategy: 'Preserve the source envelope and verify blocked notification delivery.'
645
+ });
646
+ assert.deepEqual(revision.nextTask.source, revisionSource.task.source);
647
+ assert.equal(revision.nextTask.projectId, 'demo-project');
648
+
649
+ const unroutable = { ...await readJson(revisionFailedFile) };
650
+ delete unroutable.source;
651
+ await writeJson(revisionFailedFile, unroutable);
652
+ await assert.rejects(
653
+ queueRevisionNext(root, revisionQueue, revisionSource.task.id, { force: true }),
654
+ /refusing to create an unroutable revision/
655
+ );
656
+
565
657
  console.log('route/notify self-test passed');
566
658
  await import('./config-drift-self-test.mjs');