taskforce-loop-engineering 0.8.1 → 0.8.3
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,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.3 - 2026-08-07
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Limit blocked human-input notification to the final judgement's effective checkpoint set, move blocked tasks from `failed/` into `waiting/`, and make the local Ironman scheduler run human-gate and terminal notification reconciliation after each notified tick.
|
|
7
|
+
|
|
8
|
+
## 0.8.2 - 2026-08-07
|
|
9
|
+
|
|
10
|
+
- 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.
|
|
11
|
+
- Bound long-lived project sessions by rotating the worker session after a configurable number of successful project ticks (`retry.sessionMaxTicks`, default 10).
|
|
12
|
+
|
|
3
13
|
## 0.8.1 - 2026-08-07
|
|
4
14
|
|
|
5
15
|
- 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
|
@@ -1822,7 +1822,18 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1822
1822
|
for (const [taskId, entry] of tasks) {
|
|
1823
1823
|
if (!entry.task.source) continue;
|
|
1824
1824
|
const checkpointsDir = path.join(taskRuntimeDirFor(root, queue, taskId), 'checkpoints');
|
|
1825
|
-
|
|
1825
|
+
let checkpointFiles = await listJson(checkpointsDir);
|
|
1826
|
+
const judgementFile = path.join(taskRuntimeDirFor(root, queue, taskId), 'final_judgement.json');
|
|
1827
|
+
if (await exists(judgementFile)) {
|
|
1828
|
+
const judgement = await readJson(judgementFile);
|
|
1829
|
+
const effectiveIds = judgement?.outcome === 'blocked' && Array.isArray(judgement?.coverage?.effective_review_ids)
|
|
1830
|
+
? new Set(judgement.coverage.effective_review_ids)
|
|
1831
|
+
: null;
|
|
1832
|
+
if (effectiveIds?.size > 0) {
|
|
1833
|
+
checkpointFiles = checkpointFiles.filter((file) => effectiveIds.has(path.basename(file, '.json')));
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
for (const file of checkpointFiles) {
|
|
1826
1837
|
const checkpoint = await readJson(path.join(checkpointsDir, file));
|
|
1827
1838
|
if (!['needs_human_input', 'blocked'].includes(checkpoint?.status)) continue;
|
|
1828
1839
|
const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
|
|
@@ -1830,17 +1841,17 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1830
1841
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
1831
1842
|
if (await exists(ledgerFile)) {
|
|
1832
1843
|
const gate = await readJson(ledgerFile);
|
|
1833
|
-
if (gate.status === 'waiting_for_human' && entry.subdir
|
|
1834
|
-
const
|
|
1835
|
-
if (await exists(
|
|
1836
|
-
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(
|
|
1844
|
+
if (gate.status === 'waiting_for_human' && ['inbox', 'failed'].includes(entry.subdir)) {
|
|
1845
|
+
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
1846
|
+
if (await exists(sourceFile)) {
|
|
1847
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
1837
1848
|
await writeJson(waitingFile, {
|
|
1838
1849
|
...entry.task,
|
|
1839
1850
|
status: 'waiting_for_human',
|
|
1840
1851
|
waitingGateId: gateId,
|
|
1841
1852
|
waitingSince: gate.requested_at ?? new Date().toISOString()
|
|
1842
1853
|
});
|
|
1843
|
-
await rm(
|
|
1854
|
+
await rm(sourceFile, { force: true });
|
|
1844
1855
|
}
|
|
1845
1856
|
}
|
|
1846
1857
|
results.push({ taskId, checkpointId, gateId, outcome: gate.status === 'resolved' ? 'resolved' : 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
@@ -1880,17 +1891,17 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
1880
1891
|
requested_at: new Date().toISOString(),
|
|
1881
1892
|
notification: compactCommandResult(result)
|
|
1882
1893
|
});
|
|
1883
|
-
if (entry.subdir
|
|
1884
|
-
const
|
|
1885
|
-
if (await exists(
|
|
1886
|
-
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(
|
|
1894
|
+
if (['inbox', 'failed'].includes(entry.subdir)) {
|
|
1895
|
+
const sourceFile = path.join(queueSubdirFor(root, queue, entry.subdir), `${safeTaskId(taskId)}.json`);
|
|
1896
|
+
if (await exists(sourceFile)) {
|
|
1897
|
+
const waitingFile = path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(sourceFile));
|
|
1887
1898
|
await writeJson(waitingFile, {
|
|
1888
1899
|
...entry.task,
|
|
1889
1900
|
status: 'waiting_for_human',
|
|
1890
1901
|
waitingGateId: gateId,
|
|
1891
1902
|
waitingSince: new Date().toISOString()
|
|
1892
1903
|
});
|
|
1893
|
-
await rm(
|
|
1904
|
+
await rm(sourceFile, { force: true });
|
|
1894
1905
|
}
|
|
1895
1906
|
}
|
|
1896
1907
|
results.push({ taskId, checkpointId, gateId, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
|
|
@@ -2845,6 +2856,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
2845
2856
|
milestoneId: checkpoint.milestone_id ?? null,
|
|
2846
2857
|
revisesCheckpointId: checkpoint.revises_checkpoint_id ?? null,
|
|
2847
2858
|
sequence: Number(checkpoint.sequence ?? String(checkpoint.checkpoint_id ?? '').match(/(\d+)$/)?.[1] ?? 0),
|
|
2859
|
+
checkpointCreatedAt: checkpoint.created_at ?? null,
|
|
2848
2860
|
createdAt: checkpoint.created_at ?? review.created_at,
|
|
2849
2861
|
projectCompletion: checkpoint.project_completion ?? null,
|
|
2850
2862
|
status: review.status,
|
|
@@ -2871,11 +2883,16 @@ function compareReviewSequence(a, b) {
|
|
|
2871
2883
|
}
|
|
2872
2884
|
|
|
2873
2885
|
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
2886
|
const aCheckpoint = Number(String(a.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
|
|
2877
2887
|
const bCheckpoint = Number(String(b.checkpointId ?? '').match(/(\d+)$/)?.[1] ?? 0);
|
|
2878
2888
|
if (aCheckpoint !== bCheckpoint) return aCheckpoint - bCheckpoint;
|
|
2889
|
+
// Review artifacts are regenerated as a batch. Their generated_at values
|
|
2890
|
+
// therefore describe directory traversal order, not checkpoint recency.
|
|
2891
|
+
// Only trust a timestamp that came from the checkpoint itself.
|
|
2892
|
+
const checkpointCreatedDelta = String(a.checkpointCreatedAt ?? '').localeCompare(String(b.checkpointCreatedAt ?? ''));
|
|
2893
|
+
if (checkpointCreatedDelta !== 0 && a.checkpointCreatedAt && b.checkpointCreatedAt) return checkpointCreatedDelta;
|
|
2894
|
+
const sequenceDelta = Number(a.sequence ?? 0) - Number(b.sequence ?? 0);
|
|
2895
|
+
if (sequenceDelta !== 0) return sequenceDelta;
|
|
2879
2896
|
return compareReviewSequence(a, b);
|
|
2880
2897
|
}
|
|
2881
2898
|
|
|
@@ -2912,10 +2929,19 @@ export function buildFinalJudgement(contract, acceptancePlan, devPlan, checkpoin
|
|
|
2912
2929
|
const reviseCount = effectiveReviews.filter((item) => item.status === 'revise').length;
|
|
2913
2930
|
const blockedCount = effectiveReviews.filter((item) => item.status === 'blocked').length;
|
|
2914
2931
|
const dispatchStatus = context.dispatchStatus ?? 'unknown';
|
|
2932
|
+
const dispatchFailure = context.dispatchFailureClassification ?? null;
|
|
2915
2933
|
const projectCompletionAccepted = effectiveReviews.some((review) => review.projectCompletion?.status === 'accepted');
|
|
2916
2934
|
let outcome = 'needs_revision';
|
|
2917
2935
|
|
|
2918
|
-
if (dispatchStatus === '
|
|
2936
|
+
if (dispatchStatus === 'runtime_interrupted') {
|
|
2937
|
+
outcome = 'runtime_interrupted';
|
|
2938
|
+
reasons.push(`A recoverable runtime interruption stopped the worker${dispatchFailure?.category ? ` (${dispatchFailure.category})` : ''}.`);
|
|
2939
|
+
nextActions.push('Rotate the worker session and resume from the latest durable checkpoint without creating a development revision.');
|
|
2940
|
+
} else if (dispatchStatus === 'runtime_blocked') {
|
|
2941
|
+
outcome = 'runtime_blocked';
|
|
2942
|
+
reasons.push('Recoverable runtime interruption retries were exhausted.');
|
|
2943
|
+
nextActions.push('Inspect the runtime transport/session failure before resuming the project.');
|
|
2944
|
+
} else if (dispatchStatus === 'blocked_preflight') {
|
|
2919
2945
|
outcome = 'blocked';
|
|
2920
2946
|
reasons.push('Preflight blocked the task before development dispatch.');
|
|
2921
2947
|
nextActions.push('Resolve preflight findings and rerun the queue task.');
|
|
@@ -3031,6 +3057,8 @@ function queueStatusFromFinalJudgement(currentStatus, finalJudgement) {
|
|
|
3031
3057
|
const outcome = finalJudgement.judgement.outcome;
|
|
3032
3058
|
if (outcome === 'ready_to_apply') return currentStatus;
|
|
3033
3059
|
if (outcome === 'project_in_progress') return outcome;
|
|
3060
|
+
if (outcome === 'runtime_interrupted') return outcome;
|
|
3061
|
+
if (outcome === 'runtime_blocked') return outcome;
|
|
3034
3062
|
if (outcome === 'ready_for_human_review') return outcome;
|
|
3035
3063
|
return outcome;
|
|
3036
3064
|
}
|
|
@@ -7827,7 +7855,16 @@ const DEFAULT_REQUIRES_HUMAN_ACTION_PATTERNS = [
|
|
|
7827
7855
|
'用户限制'
|
|
7828
7856
|
];
|
|
7829
7857
|
|
|
7830
|
-
|
|
7858
|
+
const DEFAULT_RECOVERABLE_RUNTIME_PATTERNS = [
|
|
7859
|
+
{ category: 'compaction_timeout', pattern: 'compaction timed out' },
|
|
7860
|
+
{ category: 'compaction_timeout', pattern: 'transcript compaction failed' },
|
|
7861
|
+
{ category: 'transport_error', pattern: 'connection reset' },
|
|
7862
|
+
{ category: 'transport_error', pattern: 'socket hang up' },
|
|
7863
|
+
{ category: 'transport_error', pattern: 'network error' },
|
|
7864
|
+
{ category: 'rate_limit', pattern: 'rate limit' }
|
|
7865
|
+
];
|
|
7866
|
+
|
|
7867
|
+
export function dispatchFailureClassification(result, retry = {}) {
|
|
7831
7868
|
if (!result || result.exitCode === 0) {
|
|
7832
7869
|
return {
|
|
7833
7870
|
category: 'ok',
|
|
@@ -7839,6 +7876,7 @@ function dispatchFailureClassification(result, retry = {}) {
|
|
|
7839
7876
|
return {
|
|
7840
7877
|
category: 'timeout',
|
|
7841
7878
|
requiresHumanAction: false,
|
|
7879
|
+
recoverableRuntime: true,
|
|
7842
7880
|
matchedPattern: null
|
|
7843
7881
|
};
|
|
7844
7882
|
}
|
|
@@ -7849,12 +7887,24 @@ function dispatchFailureClassification(result, retry = {}) {
|
|
|
7849
7887
|
return {
|
|
7850
7888
|
category: 'requires_human_action',
|
|
7851
7889
|
requiresHumanAction: true,
|
|
7890
|
+
recoverableRuntime: false,
|
|
7852
7891
|
matchedPattern: matched
|
|
7853
7892
|
};
|
|
7854
7893
|
}
|
|
7894
|
+
const runtimePatterns = retry.recoverableRuntimePatterns ?? DEFAULT_RECOVERABLE_RUNTIME_PATTERNS;
|
|
7895
|
+
const runtimeMatch = runtimePatterns.find((entry) => output.includes(String(entry.pattern ?? entry).toLowerCase()));
|
|
7896
|
+
if (runtimeMatch) {
|
|
7897
|
+
return {
|
|
7898
|
+
category: runtimeMatch.category ?? 'runtime_interruption',
|
|
7899
|
+
requiresHumanAction: false,
|
|
7900
|
+
recoverableRuntime: true,
|
|
7901
|
+
matchedPattern: runtimeMatch.pattern ?? runtimeMatch
|
|
7902
|
+
};
|
|
7903
|
+
}
|
|
7855
7904
|
return {
|
|
7856
7905
|
category: 'retryable_failure',
|
|
7857
7906
|
requiresHumanAction: false,
|
|
7907
|
+
recoverableRuntime: false,
|
|
7858
7908
|
matchedPattern: null
|
|
7859
7909
|
};
|
|
7860
7910
|
}
|
|
@@ -8264,6 +8314,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8264
8314
|
const runContext = {
|
|
8265
8315
|
env: {
|
|
8266
8316
|
...taskPlanningEnv(root, taskContract, acceptancePlan, devPlan),
|
|
8317
|
+
LOOP_SESSION_GENERATION: String(task.runtimeSessionGeneration ?? 0),
|
|
8267
8318
|
LOOP_HUMAN_INPUT_CONTEXT_FILE: humanInputContextFile,
|
|
8268
8319
|
LOOP_HUMAN_INPUT_CONTEXT_FILE_REL: path.relative(root, humanInputContextFile)
|
|
8269
8320
|
},
|
|
@@ -8312,9 +8363,17 @@ export async function runQueueOnce(root, options) {
|
|
|
8312
8363
|
const verifyOk = verification.every((entry) => entry.result.exitCode === 0);
|
|
8313
8364
|
finalStatus = verifyOk ? 'completed' : 'verify_failed';
|
|
8314
8365
|
} else {
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8366
|
+
if (dispatch?.exitCode === 0) {
|
|
8367
|
+
finalStatus = 'completed';
|
|
8368
|
+
} else if (dispatchClassification?.requiresHumanAction) {
|
|
8369
|
+
finalStatus = 'needs_human_input';
|
|
8370
|
+
} else if (dispatchClassification?.recoverableRuntime) {
|
|
8371
|
+
const recoveryCount = Number(task.runtimeRecoveryCount ?? 0);
|
|
8372
|
+
const recoveryMax = Number(options.retry?.runtimeRecoveryMaxAttempts ?? 2);
|
|
8373
|
+
finalStatus = recoveryCount < recoveryMax ? 'runtime_interrupted' : 'runtime_blocked';
|
|
8374
|
+
} else {
|
|
8375
|
+
finalStatus = 'failed';
|
|
8376
|
+
}
|
|
8318
8377
|
}
|
|
8319
8378
|
}
|
|
8320
8379
|
worktreeInspection = await inspectWorktree(worktree);
|
|
@@ -8361,6 +8420,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8361
8420
|
});
|
|
8362
8421
|
if (!finalJudgement) finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
|
|
8363
8422
|
dispatchStatus: finalStatus,
|
|
8423
|
+
dispatchFailureClassification: dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null,
|
|
8364
8424
|
verificationFailed: verification.some((entry) => entry.result.exitCode !== 0)
|
|
8365
8425
|
});
|
|
8366
8426
|
progress.emit('final-judge', finalJudgement.judgement.outcome, `Final judgement: ${finalJudgement.judgement.outcome}`, {
|
|
@@ -8397,6 +8457,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8397
8457
|
if (!finalJudgement) {
|
|
8398
8458
|
finalJudgement = await writeFinalJudgement(root, queue, task, taskContract, acceptancePlan, devPlan, checkpoints, acceptanceReviews, {
|
|
8399
8459
|
dispatchStatus: finalStatus,
|
|
8460
|
+
dispatchFailureClassification: dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null,
|
|
8400
8461
|
verificationFailed: verification.some((entry) => entry.result.exitCode !== 0)
|
|
8401
8462
|
});
|
|
8402
8463
|
finalStatus = queueStatusFromFinalJudgement(finalStatus, finalJudgement);
|
|
@@ -8405,13 +8466,13 @@ export async function runQueueOnce(root, options) {
|
|
|
8405
8466
|
revisionRequest = await writeRevisionRequest(root, queue, task, taskContract, acceptancePlan, devPlan, finalJudgement, acceptanceReviews);
|
|
8406
8467
|
}
|
|
8407
8468
|
|
|
8408
|
-
destination =
|
|
8469
|
+
destination = ['project_in_progress', 'runtime_interrupted'].includes(finalStatus)
|
|
8409
8470
|
? queueSubdirFor(root, queue, 'inbox')
|
|
8410
8471
|
: finalStatus === 'completed'
|
|
8411
8472
|
? queueSubdirFor(root, queue, 'done')
|
|
8412
8473
|
: finalStatus === 'superseded' ? queueSubdirFor(root, queue, 'canceled')
|
|
8413
8474
|
: queueSubdirFor(root, queue, 'failed');
|
|
8414
|
-
exitCode = ['completed', 'superseded', 'project_in_progress'].includes(finalStatus) ? 0 : 1;
|
|
8475
|
+
exitCode = ['completed', 'superseded', 'project_in_progress', 'runtime_interrupted'].includes(finalStatus) ? 0 : 1;
|
|
8415
8476
|
|
|
8416
8477
|
const finishedAt = new Date().toISOString();
|
|
8417
8478
|
progress.emit('queue', finalStatus === 'completed' ? 'completed' : finalStatus === 'project_in_progress' ? 'continued' : 'needs_attention', `Task finished with status ${finalStatus}`, {
|
|
@@ -8421,7 +8482,7 @@ export async function runQueueOnce(root, options) {
|
|
|
8421
8482
|
});
|
|
8422
8483
|
const completedTask = {
|
|
8423
8484
|
...task,
|
|
8424
|
-
status:
|
|
8485
|
+
status: ['project_in_progress', 'runtime_interrupted'].includes(finalStatus) ? 'queued' : finalStatus,
|
|
8425
8486
|
startedAt,
|
|
8426
8487
|
finishedAt,
|
|
8427
8488
|
attempts: (task.attempts ?? 0) + Math.max(dispatchAttempts.length, dispatch ? 1 : 0),
|
|
@@ -8430,6 +8491,19 @@ export async function runQueueOnce(root, options) {
|
|
|
8430
8491
|
if (finalStatus === 'project_in_progress') {
|
|
8431
8492
|
completedTask.projectContinuedAt = finishedAt;
|
|
8432
8493
|
completedTask.projectContinuationCount = (task.projectContinuationCount ?? 0) + 1;
|
|
8494
|
+
completedTask.runtimeRecoveryCount = 0;
|
|
8495
|
+
const sessionMaxTicks = Math.max(1, Number(options.retry?.sessionMaxTicks ?? 10));
|
|
8496
|
+
if (completedTask.projectContinuationCount % sessionMaxTicks === 0) {
|
|
8497
|
+
completedTask.runtimeSessionGeneration = Number(task.runtimeSessionGeneration ?? 0) + 1;
|
|
8498
|
+
completedTask.sessionRotatedAt = finishedAt;
|
|
8499
|
+
completedTask.sessionRotationReason = 'bounded_tick_limit';
|
|
8500
|
+
}
|
|
8501
|
+
}
|
|
8502
|
+
if (finalStatus === 'runtime_interrupted') {
|
|
8503
|
+
completedTask.runtimeRecoveryCount = Number(task.runtimeRecoveryCount ?? 0) + 1;
|
|
8504
|
+
completedTask.runtimeSessionGeneration = Number(task.runtimeSessionGeneration ?? 0) + 1;
|
|
8505
|
+
completedTask.runtimeInterruptedAt = finishedAt;
|
|
8506
|
+
completedTask.runtimeInterruption = dispatchAttempts[dispatchAttempts.length - 1]?.failureClassification ?? null;
|
|
8433
8507
|
}
|
|
8434
8508
|
const completedFile = path.join(destination, path.basename(activeFile));
|
|
8435
8509
|
await writeJson(completedFile, completedTask);
|
package/package.json
CHANGED
|
@@ -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,49 @@ 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 devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
21
|
+
const reviews = {
|
|
22
|
+
reviews: [
|
|
23
|
+
// Acceptance reviews are regenerated in filename traversal order. A
|
|
24
|
+
// legacy cp9 review can consequently have a later review timestamp than
|
|
25
|
+
// the real latest cp46 checkpoint; that timestamp must not win.
|
|
26
|
+
{ checkpointId: 'cp46', sequence: 9, createdAt: '2026-08-07T18:27:18.990Z', status: 'blocked' },
|
|
27
|
+
{ checkpointId: 'cp9', sequence: 1, createdAt: '2026-08-07T18:27:18.992Z', status: 'accepted' }
|
|
28
|
+
]
|
|
29
|
+
};
|
|
30
|
+
assert.equal(selectEffectiveAcceptanceReviews(devPlan, reviews)[0].checkpointId, 'cp46');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
const classification = dispatchFailureClassification({
|
|
35
|
+
exitCode: 1,
|
|
36
|
+
timedOut: false,
|
|
37
|
+
stderr: 'CLI transcript compaction failed: Compaction timed out',
|
|
38
|
+
stdout: ''
|
|
39
|
+
});
|
|
40
|
+
assert.equal(classification.category, 'compaction_timeout');
|
|
41
|
+
assert.equal(classification.recoverableRuntime, true);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
46
|
+
const reviews = { reviews: [{ checkpointId: 'cp38', sequence: 38, status: 'accepted', projectCompletion: { status: 'in_progress' } }] };
|
|
47
|
+
const judgement = buildFinalJudgement(
|
|
48
|
+
{ ...baseContract, task_scope: 'project' },
|
|
49
|
+
basePlan,
|
|
50
|
+
devPlan,
|
|
51
|
+
{ count: 38 },
|
|
52
|
+
reviews,
|
|
53
|
+
{
|
|
54
|
+
dispatchStatus: 'runtime_interrupted',
|
|
55
|
+
dispatchFailureClassification: { category: 'compaction_timeout', recoverableRuntime: true }
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
assert.equal(judgement.outcome, 'runtime_interrupted');
|
|
59
|
+
assert.equal(judgement.reasons.some((reason) => reason.includes('development')), false);
|
|
60
|
+
}
|
|
61
|
+
|
|
19
62
|
{
|
|
20
63
|
const devPlan = { checkpoints: [{ id: 'cp1' }] };
|
|
21
64
|
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,
|
|
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);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtemp, readdir, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { access, 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 {
|
|
@@ -371,6 +371,8 @@ assert.match(gateDryRun.results[0].message, /Provide the SMS code/);
|
|
|
371
371
|
const gateSent = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
372
372
|
assert.equal(gateSent.sent, 1);
|
|
373
373
|
const gateId = gateSent.results[0].gateId;
|
|
374
|
+
assert.equal((await readJson(path.join(queueSubdirFor(root, queue, 'waiting'), path.basename(failedFile)))).status, 'waiting_for_human');
|
|
375
|
+
await assert.rejects(access(failedFile));
|
|
374
376
|
const resolved = await resolveHumanInput(root, { queue, gateId, input: '123456', sourceMessageId: 'reply-1' });
|
|
375
377
|
assert.equal(resolved.outcome, 'resolved_and_requeued');
|
|
376
378
|
const requeuedTask = await readJson(path.join(queueSubdirFor(root, queue, 'inbox'), path.basename(failedFile)));
|