taskforce-loop-engineering 0.8.1 → 0.8.2

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 - 2026-08-07
4
+
5
+ - Classify transcript-compaction timeouts and selected transport failures as recoverable runtime interruptions instead of development revisions. Preserve accepted checkpoints, rotate the worker session, and return the same project task to the queue with bounded recovery attempts.
6
+ - Bound long-lived project sessions by rotating the worker session after a configurable number of successful project ticks (`retry.sessionMaxTicks`, default 10).
7
+
3
8
  ## 0.8.1 - 2026-08-07
4
9
 
5
10
  - Make the standard OpenClaw installer and upgrade path install and enable a managed per-queue systemd user scheduler, configure required scheduler heartbeats by default, and use an absolute packaged CLI path so systemd does not depend on an interactive shell `PATH`.
package/lib/core.mjs CHANGED
@@ -2912,10 +2912,19 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
2912
2912
  const reviseCount = effectiveReviews.filter((item) => item.status === 'revise').length;
2913
2913
  const blockedCount = effectiveReviews.filter((item) => item.status === 'blocked').length;
2914
2914
  const dispatchStatus = context.dispatchStatus ?? 'unknown';
2915
+ const dispatchFailure = context.dispatchFailureClassification ?? null;
2915
2916
  const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
2916
2917
  let outcome = 'needs_revision';
2917
2918
 
2918
- if (dispatchStatus === 'blocked_preflight') {
2919
+ if (dispatchStatus === 'runtime_interrupted') {
2920
+ outcome = 'runtime_interrupted';
2921
+ reasons.push(`A recoverable runtime interruption stopped the worker${dispatchFailure?.category ? ` (${dispatchFailure.category})` : ''}.`);
2922
+ nextActions.push('Rotate the worker session and resume from the latest durable checkpoint without creating a development revision.');
2923
+ } else if (dispatchStatus === 'runtime_blocked') {
2924
+ outcome = 'runtime_blocked';
2925
+ reasons.push('Recoverable runtime interruption retries were exhausted.');
2926
+ nextActions.push('Inspect the runtime transport/session failure before resuming the project.');
2927
+ } else if (dispatchStatus === 'blocked_preflight') {
2919
2928
  outcome = 'blocked';
2920
2929
  reasons.push('Preflight blocked the task before development dispatch.');
2921
2930
  nextActions.push('Resolve preflight findings and rerun the queue task.');
@@ -3031,6 +3040,8 @@ function queueStatusFromFinalJudgement(currentStatus, finalJudgement) {
3031
3040
  const outcome = finalJudgement.judgement.outcome;
3032
3041
  if (outcome === 'ready_to_apply') return currentStatus;
3033
3042
  if (outcome === 'project_in_progress') return outcome;
3043
+ if (outcome === 'runtime_interrupted') return outcome;
3044
+ if (outcome === 'runtime_blocked') return outcome;
3034
3045
  if (outcome === 'ready_for_human_review') return outcome;
3035
3046
  return outcome;
3036
3047
  }
@@ -7827,7 +7838,16 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
7827
7838
  '用户限制'
7828
7839
  ];
7829
7840
 
7830
- function dispatchFailureClassification(result, retry = {}) {
7841
+ const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
7842
+ { category: 'compaction_timeout', pattern: 'compaction timed out' },
7843
+ { category: 'compaction_timeout', pattern: 'transcript compaction failed' },
7844
+ { category: 'transport_error', pattern: 'connection reset' },
7845
+ { category: 'transport_error', pattern: 'socket hang up' },
7846
+ { category: 'transport_error', pattern: 'network error' },
7847
+ { category: 'rate_limit', pattern: 'rate limit' }
7848
+ ];
7849
+
7850
+ export function dispatchFailureClassification(result, retry = {}) {
7831
7851
  if (!result || result.exitCode === 0) {
7832
7852
  return {
7833
7853
  category: 'ok',
@@ -7839,6 +7859,7 @@ function dispatchFailureClassification(result, retry = {}) {
7839
7859
  return {
7840
7860
  category: 'timeout',
7841
7861
  requiresHumanAction: false,
7862
+ recoverableRuntime: true,
7842
7863
  matchedPattern: null
7843
7864
  };
7844
7865
  }
@@ -7849,12 +7870,24 @@ function dispatchFailureClassification(result, retry = {}) {
7849
7870
  return {
7850
7871
  category: 'requires_human_action',
7851
7872
  requiresHumanAction: true,
7873
+ recoverableRuntime: false,
7852
7874
  matchedPattern: matched
7853
7875
  };
7854
7876
  }
7877
+ const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
7878
+ const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
7879
+ if (runtimeMatch) {
7880
+ return {
7881
+ category: runtimeMatch.category ?? 'runtime_interruption',
7882
+ requiresHumanAction: false,
7883
+ recoverableRuntime: true,
7884
+ matchedPattern: runtimeMatch.pattern ?? runtimeMatch
7885
+ };
7886
+ }
7855
7887
  return {
7856
7888
  category: 'retryable_failure',
7857
7889
  requiresHumanAction: false,
7890
+ recoverableRuntime: false,
7858
7891
  matchedPattern: null
7859
7892
  };
7860
7893
  }
@@ -8264,6 +8297,7 @@ export async function runQueueOnce(root, options) {
8264
8297
  const runContext = {
8265
8298
  env: {
8266
8299
  ...taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
8300
+ LOOP_SESSION_GENERATION: String(task.runtimeSessionGeneration ?? 0),
8267
8301
  LOOP_HUMAN_INPUT_CONTEXT_FILE: humanInputContextFile,
8268
8302
  LOOP_HUMAN_INPUT_CONTEXT_FILE_REL: path.relative(root, humanInputContextFile)
8269
8303
  },
@@ -8312,9 +8346,17 @@ export async function runQueueOnce(root, options) {
8312
8346
  const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
8313
8347
  finalStatus = verifyOk ? 'completed' : 'verify_failed';
8314
8348
  } else {
8315
- finalStatus = dispatch?.exitCode === 0
8316
- ? 'completed'
8317
- : dispatchClassification?.requiresHumanAction ? 'needs_human_input' : 'failed';
8349
+ if (dispatch?.exitCode === 0) {
8350
+ finalStatus = 'completed';
8351
+ } else if (dispatchClassification?.requiresHumanAction) {
8352
+ finalStatus = 'needs_human_input';
8353
+ } else if (dispatchClassification?.recoverableRuntime) {
8354
+ const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
8355
+ const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
8356
+ finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
8357
+ } else {
8358
+ finalStatus = 'failed';
8359
+ }
8318
8360
  }
8319
8361
  }
8320
8362
  worktreeInspection = await inspectWorktree(worktree);
@@ -8361,6 +8403,7 @@ export async function runQueueOnce(root, options) {
8361
8403
  });
8362
8404
  if (!finalJudgement) finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
8363
8405
  dispatchStatus: finalStatus,
8406
+ dispatchFailureClassification: dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null,
8364
8407
  verificationFailed: verification.some((entry) => entry.result.exitCode !== 0)
8365
8408
  });
8366
8409
  progress.emit('final-judge', finalJudgement.judgement.outcome, `Final judgement: ${finalJudgement.judgement.outcome}`, {
@@ -8397,6 +8440,7 @@ export async function runQueueOnce(root, options) {
8397
8440
  if (!finalJudgement) {
8398
8441
  finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
8399
8442
  dispatchStatus: finalStatus,
8443
+ dispatchFailureClassification: dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null,
8400
8444
  verificationFailed: verification.some((entry) => entry.result.exitCode !== 0)
8401
8445
  });
8402
8446
  finalStatus = queueStatusFromFinalJudgement(finalStatus, finalJudgement);
@@ -8405,13 +8449,13 @@ export async function runQueueOnce(root, options) {
8405
8449
  revisionRequest = await writeRevisionRequest(root, queue, task, taskContract, acceptancePlan, devPlan, finalJudgement, acceptanceReviews);
8406
8450
  }
8407
8451
 
8408
- destination = finalStatus === 'project_in_progress'
8452
+ destination = ['project_in_progress', 'runtime_interrupted'].includes(finalStatus)
8409
8453
  ? queueSubdirFor(root, queue, 'inbox')
8410
8454
  : finalStatus === 'completed'
8411
8455
  ? queueSubdirFor(root, queue, 'done')
8412
8456
  : finalStatus === 'superseded' ? queueSubdirFor(root, queue, 'canceled')
8413
8457
  : queueSubdirFor(root, queue, 'failed');
8414
- exitCode = ['completed', 'superseded', 'project_in_progress'].includes(finalStatus) ? 0 : 1;
8458
+ exitCode = ['completed', 'superseded', 'project_in_progress', 'runtime_interrupted'].includes(finalStatus) ? 0 : 1;
8415
8459
 
8416
8460
  const finishedAt = new Date().toISOString();
8417
8461
  progress.emit('queue', finalStatus === 'completed' ? 'completed' : finalStatus === 'project_in_progress' ? 'continued' : 'needs_attention', `Task finished with status ${finalStatus}`, {
@@ -8421,7 +8465,7 @@ export async function runQueueOnce(root, options) {
8421
8465
  });
8422
8466
  const completedTask = {
8423
8467
  ...task,
8424
- status: finalStatus === 'project_in_progress' ? 'queued' : finalStatus,
8468
+ status: ['project_in_progress', 'runtime_interrupted'].includes(finalStatus) ? 'queued' : finalStatus,
8425
8469
  startedAt,
8426
8470
  finishedAt,
8427
8471
  attempts: (task.attempts ?? 0) + Math.max(dispatchAttempts.length, dispatch ? 1 : 0),
@@ -8430,6 +8474,19 @@ export async function runQueueOnce(root, options) {
8430
8474
  if (finalStatus === 'project_in_progress') {
8431
8475
  completedTask.projectContinuedAt = finishedAt;
8432
8476
  completedTask.projectContinuationCount = (task.projectContinuationCount ?? 0) + 1;
8477
+ completedTask.runtimeRecoveryCount = 0;
8478
+ const sessionMaxTicks = Math.max(1, Number(options.retry?.sessionMaxTicks ?? 10));
8479
+ if (completedTask.projectContinuationCount % sessionMaxTicks === 0) {
8480
+ completedTask.runtimeSessionGeneration = Number(task.runtimeSessionGeneration ?? 0) + 1;
8481
+ completedTask.sessionRotatedAt = finishedAt;
8482
+ completedTask.sessionRotationReason = 'bounded_tick_limit';
8483
+ }
8484
+ }
8485
+ if (finalStatus === 'runtime_interrupted') {
8486
+ completedTask.runtimeRecoveryCount = Number(task.runtimeRecoveryCount ?? 0) + 1;
8487
+ completedTask.runtimeSessionGeneration = Number(task.runtimeSessionGeneration ?? 0) + 1;
8488
+ completedTask.runtimeInterruptedAt = finishedAt;
8489
+ completedTask.runtimeInterruption = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
8433
8490
  }
8434
8491
  const completedFile = path.join(destination, path.basename(activeFile));
8435
8492
  await writeJson(completedFile, completedTask);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskforce-loop-engineering",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "private": false,
5
5
  "description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert/strict';
2
- import { buildFinalJudgement, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
2
+ import { buildFinalJudgement, dispatchFailureClassification, selectEffectiveAcceptanceReviews } from '../lib/core.mjs';
3
3
 
4
4
  const basePlan = { rubric: [], automation: [] };
5
5
  const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: false, task_scope: 'scoped_task' };
@@ -16,6 +16,35 @@ const baseContract = { task_id: 't1', risk_level: 'L1', requires_human_gate: fal
16
16
  assert.equal(selectEffectiveAcceptanceReviews(devPlan, reviews)[0].checkpointId, 'cp10');
17
17
  }
18
18
 
19
+ {
20
+ const classification = dispatchFailureClassification({
21
+ exitCode: 1,
22
+ timedOut: false,
23
+ stderr: 'CLI transcript compaction failed: Compaction timed out',
24
+ stdout: ''
25
+ });
26
+ assert.equal(classification.category, 'compaction_timeout');
27
+ assert.equal(classification.recoverableRuntime, true);
28
+ }
29
+
30
+ {
31
+ const devPlan = { checkpoints: [{ id: 'cp1' }] };
32
+ const reviews = { reviews: [{ checkpointId: 'cp38', sequence: 38, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
33
+ const judgement = buildFinalJudgement(
34
+ { ...baseContract, task_scope: 'project' },
35
+ basePlan,
36
+ devPlan,
37
+ { count: 38 },
38
+ reviews,
39
+ {
40
+ dispatchStatus: 'runtime_interrupted',
41
+ dispatchFailureClassification: { category: 'compaction_timeout', recoverableRuntime: true }
42
+ }
43
+ );
44
+ assert.equal(judgement.outcome, 'runtime_interrupted');
45
+ assert.equal(judgement.reasons.some((reason) => reason.includes('development')), false);
46
+ }
47
+
19
48
  {
20
49
  const devPlan = { checkpoints: [{ id: 'cp1' }] };
21
50
  const reviews = {
@@ -55,7 +55,8 @@ const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/te
55
55
  if (queue.dispatcher !== 'node scripts/loops/openclaw-loop-dispatch.mjs') throw new Error('dispatcher was not installed');
56
56
  if (queue.scheduler?.required !== true || queue.scheduler?.heartbeatMaxAgeMs !== 300000) throw new Error('required scheduler heartbeat was not installed');
57
57
  const dispatcher = await readFile(path.join(root, 'scripts/loops/openclaw-loop-dispatch.mjs'), 'utf8');
58
- if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE')) throw new Error('worker, recursion guard, or amendment polling missing');
58
+ if (!dispatcher.includes('already loop-managed') || !dispatcher.includes("'--agent', \"builder\"") || !dispatcher.includes('LOOP_LATEST_AMENDMENT_FILE') || !dispatcher.includes('LOOP_SESSION_GENERATION') || !dispatcher.includes('-g${sessionGeneration}')) throw new Error('worker, recursion guard, amendment polling, or session generation missing');
59
+ if (queue.retry?.runtimeRecoveryMaxAttempts !== 2 || queue.retry?.sessionMaxTicks !== 10) throw new Error('bounded runtime recovery policy was not installed');
59
60
  const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
60
61
  if (!instructions.includes('走 loop') || !instructions.includes('immediately execute')) throw new Error('conversation instructions missing');
61
62
  const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
@@ -68,6 +68,7 @@ function dispatcherSource({ workerAgent, openclawBin }) {
68
68
  import { readFile } from 'node:fs/promises';
69
69
  import { spawn } from 'node:child_process';
70
70
  const task = JSON.parse(await readFile(process.env.LOOP_TASK_FILE, 'utf8'));
71
+ const sessionGeneration = Number.parseInt(process.env.LOOP_SESSION_GENERATION || '0', 10) || 0;
71
72
  const prompt = [
72
73
  'You are receiving an already loop-managed task.',
73
74
  'Do not route or enqueue this task again, even if its quoted request contains a loop trigger.',
@@ -84,7 +85,7 @@ const prompt = [
84
85
  ].join('\\n');
85
86
  const child = spawn(${JSON.stringify(openclawBin)}, [
86
87
  'agent', '--agent', ${JSON.stringify(workerAgent)},
87
- '--session-key', \`agent:${workerAgent}:loop-task-\${task.id}\`,
88
+ '--session-key', \`agent:${workerAgent}:loop-task-\${task.id}-g\${sessionGeneration}\`,
88
89
  '--message', prompt, '--json', '--timeout', '1800'
89
90
  ], { cwd: process.cwd(), env: process.env, stdio: 'inherit' });
90
91
  child.on('close', (code, signal) => { process.exitCode = code ?? (signal ? 128 : 1); });
@@ -228,7 +229,7 @@ async function main() {
228
229
  preflightConfig: 'configs/loops/workspace-health.json',
229
230
  timeoutMs: 1800000, leaseMs: 1860000, staleActiveMs: 3600000,
230
231
  scheduler: { required: true, heartbeatMaxAgeMs: 300000, initialInterval: '1m', minInterval: '1m', maxInterval: '4h', speedupFactor: 0.5, backoffFactor: 2, idleBackoffFactor: 2, humanGateBackoffFactor: 3, longRunHeadroomFactor: 1.25, jitter: '10s' },
231
- retry: { maxAttempts: 1, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] },
232
+ retry: { maxAttempts: 1, runtimeRecoveryMaxAttempts: 2, sessionMaxTicks: 10, retryDelayMs: 0, retryExitCodes: [1], requiresHumanActionPatterns: ['requires human', '需要人工', 'Permission denied', 'Operation not permitted'] },
232
233
  revisionPolicy: { enabled: true, maxRevisionRounds: 3, sameFailureThreshold: 2, requireStrategyChange: true }
233
234
  }, null, 2)}\n`;
234
235
  const dispatcherContent = dispatcherSource(args);