taskforce-loop-engineering 0.15.4 → 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,13 @@
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
+
5
12
  ## 0.15.4 - 2026-08-22
6
13
 
7
14
  - Preserve source conversation and project metadata across revision planning, direct revision enqueue, and saved-plan application.
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 });
@@ -2399,6 +2414,43 @@ function materializableDeferredGates(checkpoint) {
2399
2414
  });
2400
2415
  }
2401
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
+
2402
2454
  async function tasksById(root, queue) {
2403
2455
  const tasks = new Map();
2404
2456
  for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
@@ -2452,9 +2504,14 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
2452
2504
  const deferredOnly = latestDeferredGates.length > 0
2453
2505
  && (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
2454
2506
  && !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
2455
- const spec = specs.find((item) => item.project === metadata.project_id);
2507
+ const spec = await projectSpecForCheckpoint(root, specs, metadata.project_id, latestCheckpoint);
2456
2508
  if (deferredOnly && await projectTerminalAccepted(root, spec)) continue;
2457
- 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] });
2458
2515
  }
2459
2516
 
2460
2517
  // A project deferred gate is materialized only from its newest authoritative
@@ -8731,6 +8788,9 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
8731
8788
  ];
8732
8789
 
8733
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' },
8734
8794
  { category: 'compaction_timeout', pattern: 'compaction timed out' },
8735
8795
  { category: 'compaction_timeout', pattern: 'transcript compaction failed' },
8736
8796
  { category: 'transport_error', pattern: 'connection reset' },
@@ -8740,7 +8800,28 @@ const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
8740
8800
  ];
8741
8801
 
8742
8802
  export function dispatchFailureClassification(result, retry = {}) {
8743
- 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) {
8744
8825
  return {
8745
8826
  category: 'ok',
8746
8827
  requiresHumanAction: false,
@@ -8755,7 +8836,6 @@ export function dispatchFailureClassification(result, retry = {}) {
8755
8836
  matchedPattern: null
8756
8837
  };
8757
8838
  }
8758
- const output = `${result.stderr ?? ''}\n${result.stdout ?? ''}`.toLowerCase();
8759
8839
  const patterns = retry.requiresHumanActionPatterns ?? DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS;
8760
8840
  const matched = patterns.find((pattern) => output.includes(pattern.toLowerCase()));
8761
8841
  if (matched) {
@@ -8766,16 +8846,6 @@ export function dispatchFailureClassification(result, retry = {}) {
8766
8846
  matchedPattern: matched
8767
8847
  };
8768
8848
  }
8769
- const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
8770
- const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
8771
- if (runtimeMatch) {
8772
- return {
8773
- category: runtimeMatch.category ?? 'runtime_interruption',
8774
- requiresHumanAction: false,
8775
- recoverableRuntime: true,
8776
- matchedPattern: runtimeMatch.pattern ?? runtimeMatch
8777
- };
8778
- }
8779
8849
  return {
8780
8850
  category: 'retryable_failure',
8781
8851
  requiresHumanAction: false,
@@ -8908,9 +8978,11 @@ async function runDispatchWithRetry(root, options, queue, task, activeFile, runI
8908
8978
  result,
8909
8979
  failureClassification
8910
8980
  });
8911
- const dispatchStatus = result.exitCode === 0
8912
- ? 'passed'
8913
- : 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';
8914
8986
  runContext.progress?.emit('dispatch', dispatchStatus, `Dispatcher attempt ${attempt} exited ${result.exitCode}`, {
8915
8987
  attempt,
8916
8988
  exitCode: result.exitCode,
@@ -9237,6 +9309,10 @@ export async function runQueueOnce(root, options) {
9237
9309
  const dispatchClassification = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
9238
9310
  if (dispatch?.canceled) {
9239
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';
9240
9316
  } else if (dispatch?.exitCode === 0 && worktreeEnabled(options)) {
9241
9317
  verification = await runVerifyCommands(options.worktree?.verifyCommands ?? [], worktree.path, timeoutMs, progress);
9242
9318
  const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
@@ -9246,10 +9322,6 @@ export async function runQueueOnce(root, options) {
9246
9322
  finalStatus = 'completed';
9247
9323
  } else if (dispatchClassification?.requiresHumanAction) {
9248
9324
  finalStatus = 'needs_human_input';
9249
- } else if (dispatchClassification?.recoverableRuntime) {
9250
- const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
9251
- const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
9252
- finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
9253
9325
  } else {
9254
9326
  finalStatus = 'failed';
9255
9327
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.15.4",
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'] }));