taskforce-loop-engineering 0.15.0 → 0.15.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 +10 -0
- package/lib/core.mjs +145 -15
- package/package.json +1 -1
- package/scripts/hermes-install-self-test.mjs +1 -0
- package/scripts/hermes-install.mjs +1 -1
- package/scripts/openclaw-install-self-test.mjs +1 -0
- package/scripts/openclaw-install.mjs +1 -1
- package/scripts/project-gate-reconciliation-self-test.mjs +43 -2
- package/scripts/route-notify-self-test.mjs +29 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.15.2 - 2026-08-21
|
|
6
|
+
|
|
7
|
+
- Enrich terminal notifications with final judgement, checkpoint summary, verification count, blockers, and next action.
|
|
8
|
+
- Link successful terminal notification ledgers back into queue run artifacts while preserving idempotent delivery.
|
|
9
|
+
- Materialize only actionable deferred human gates, preserve project standing authorizations in task contracts, and allocate append-only checkpoint identifiers when replanning.
|
|
10
|
+
|
|
11
|
+
## 0.15.1 - 2026-08-20
|
|
12
|
+
|
|
13
|
+
- Keep generated OpenClaw and Hermes scheduler services healthy when asynchronous human or terminal notification delivery fails, while preserving notification logging and retries.
|
|
14
|
+
|
|
5
15
|
## 0.15.0 - 2026-08-20
|
|
6
16
|
|
|
7
17
|
- Reconcile project-scoped human gates against the authoritative project contract and acceptance ledger, superseding stale or optional deferred gates without replay.
|
package/lib/core.mjs
CHANGED
|
@@ -1565,7 +1565,7 @@ function buildProjectSpec(options) {
|
|
|
1565
1565
|
assumptions: [
|
|
1566
1566
|
'Start with local artifacts and local verification.',
|
|
1567
1567
|
'Use conservative defaults when the brief leaves implementation details open.',
|
|
1568
|
-
'
|
|
1568
|
+
'Apply the configured project action policy; ask only for actions that remain human-gated there.'
|
|
1569
1569
|
]
|
|
1570
1570
|
};
|
|
1571
1571
|
}
|
|
@@ -2102,9 +2102,42 @@ async function installedQueueLanguage(root, queue, fallback = 'en') {
|
|
|
2102
2102
|
try { return normalizeLanguage((await readJson(file)).language); } catch { return normalizeLanguage(fallback); }
|
|
2103
2103
|
}
|
|
2104
2104
|
|
|
2105
|
-
function
|
|
2105
|
+
async function terminalNotificationContext(root, queue, task) {
|
|
2106
|
+
const taskDir = taskRuntimeDirFor(root, queue, task.id);
|
|
2107
|
+
const judgementFile = path.join(taskDir, 'final_judgement.json');
|
|
2108
|
+
const checkpointsDir = path.join(taskDir, 'checkpoints');
|
|
2109
|
+
const judgement = await exists(judgementFile) ? await readJson(judgementFile) : null;
|
|
2110
|
+
let checkpoint = null;
|
|
2111
|
+
const checkpointFiles = await listJson(checkpointsDir);
|
|
2112
|
+
if (checkpointFiles.length > 0) {
|
|
2113
|
+
const candidates = await Promise.all(checkpointFiles.map(async (file) => ({
|
|
2114
|
+
file,
|
|
2115
|
+
value: await readJson(path.join(checkpointsDir, file)),
|
|
2116
|
+
mtimeMs: (await stat(path.join(checkpointsDir, file))).mtimeMs
|
|
2117
|
+
})));
|
|
2118
|
+
candidates.sort((a, b) => {
|
|
2119
|
+
const sequenceDelta = Number(a.value?.sequence ?? -1) - Number(b.value?.sequence ?? -1);
|
|
2120
|
+
return sequenceDelta || a.mtimeMs - b.mtimeMs;
|
|
2121
|
+
});
|
|
2122
|
+
checkpoint = candidates.at(-1)?.value ?? null;
|
|
2123
|
+
}
|
|
2124
|
+
return { judgement, checkpoint };
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
function compactNotificationText(value, max = 500) {
|
|
2128
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
2129
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
async function terminalNotificationMessage(root, queue, task, language = 'en') {
|
|
2133
|
+
const { judgement, checkpoint } = await terminalNotificationContext(root, queue, task);
|
|
2106
2134
|
const needsReview = task.status === 'ready_for_human_review';
|
|
2107
2135
|
const needsHuman = ['needs_human_input', 'blocked', 'ready_for_human_review'].includes(task.status);
|
|
2136
|
+
const summary = compactNotificationText(checkpoint?.summary);
|
|
2137
|
+
const verificationCount = Array.isArray(checkpoint?.verification) ? checkpoint.verification.length : 0;
|
|
2138
|
+
const blockers = Array.isArray(checkpoint?.blockers) ? checkpoint.blockers.filter(Boolean) : [];
|
|
2139
|
+
const outcome = judgement?.outcome;
|
|
2140
|
+
const nextAction = compactNotificationText(judgement?.next_actions?.[0] ?? checkpoint?.next_action, 300);
|
|
2108
2141
|
if (language === 'zh') return [
|
|
2109
2142
|
needsReview ? 'Loop 任务已准备好接受人工验收'
|
|
2110
2143
|
: needsHuman ? 'Loop 任务需要人工输入'
|
|
@@ -2112,6 +2145,11 @@ function terminalNotificationMessage(queue, task, language = 'en') {
|
|
|
2112
2145
|
`任务:${task.title}`,
|
|
2113
2146
|
`队列:${queue}`,
|
|
2114
2147
|
`状态:${task.status}`,
|
|
2148
|
+
...(outcome ? [`最终判定:${outcome}`] : []),
|
|
2149
|
+
...(summary ? [`结果:${summary}`] : []),
|
|
2150
|
+
...(verificationCount ? [`验收:${verificationCount} 项验证证据已记录`] : []),
|
|
2151
|
+
...(blockers.length ? [`阻塞:${blockers.map((item) => compactNotificationText(item, 180)).join(';')}`] : []),
|
|
2152
|
+
...(nextAction ? [`下一步:${nextAction}`] : []),
|
|
2115
2153
|
...(needsReview
|
|
2116
2154
|
? [`下一步:检查最终判定,并为任务 ${task.id} 记录 approve、request_changes 或 reject。`]
|
|
2117
2155
|
: needsHuman
|
|
@@ -2125,6 +2163,11 @@ function terminalNotificationMessage(queue, task, language = 'en') {
|
|
|
2125
2163
|
`task: ${task.title}`,
|
|
2126
2164
|
`queue: ${queue}`,
|
|
2127
2165
|
`status: ${task.status}`,
|
|
2166
|
+
...(outcome ? [`final judgement: ${outcome}`] : []),
|
|
2167
|
+
...(summary ? [`result: ${summary}`] : []),
|
|
2168
|
+
...(verificationCount ? [`acceptance: ${verificationCount} verification evidence item(s) recorded`] : []),
|
|
2169
|
+
...(blockers.length ? [`blockers: ${blockers.map((item) => compactNotificationText(item, 180)).join('; ')}`] : []),
|
|
2170
|
+
...(nextAction ? [`next: ${nextAction}`] : []),
|
|
2128
2171
|
...(needsReview
|
|
2129
2172
|
? [`next: review the final judgement and record approve, request_changes, or reject for task ${task.id}.`]
|
|
2130
2173
|
: needsHuman
|
|
@@ -2133,6 +2176,22 @@ function terminalNotificationMessage(queue, task, language = 'en') {
|
|
|
2133
2176
|
].join('\n');
|
|
2134
2177
|
}
|
|
2135
2178
|
|
|
2179
|
+
async function linkTerminalNotificationToRun(root, task, ledgerFile, ledger, outcome = 'sent') {
|
|
2180
|
+
if (!task.runPath) return false;
|
|
2181
|
+
const runFile = path.resolve(root, task.runPath);
|
|
2182
|
+
if (!await exists(runFile)) return false;
|
|
2183
|
+
const run = await readJson(runFile);
|
|
2184
|
+
await writeJson(runFile, {
|
|
2185
|
+
...run,
|
|
2186
|
+
terminalNotification: {
|
|
2187
|
+
outcome,
|
|
2188
|
+
ledger: path.relative(root, ledgerFile),
|
|
2189
|
+
notifiedAt: ledger.notified_at
|
|
2190
|
+
}
|
|
2191
|
+
});
|
|
2192
|
+
return true;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2136
2195
|
export async function notifyTerminalTasks(root, options = {}) {
|
|
2137
2196
|
const queue = normalizeLoopId(options.queue);
|
|
2138
2197
|
const language = await installedQueueLanguage(root, queue, options.language);
|
|
@@ -2181,10 +2240,14 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
2181
2240
|
const key = `${safeTaskId(task.id)}.${normalizeLoopId(task.status)}.json`;
|
|
2182
2241
|
const ledgerFile = path.join(notificationDir, key);
|
|
2183
2242
|
if (await exists(ledgerFile)) {
|
|
2243
|
+
if (!options.dryRun) {
|
|
2244
|
+
const ledger = await readJson(ledgerFile);
|
|
2245
|
+
await linkTerminalNotificationToRun(root, task, ledgerFile, ledger);
|
|
2246
|
+
}
|
|
2184
2247
|
results.push({ taskId: task.id, status: task.status, outcome: 'already_notified', ledger: path.relative(root, ledgerFile) });
|
|
2185
2248
|
continue;
|
|
2186
2249
|
}
|
|
2187
|
-
const message = terminalNotificationMessage(queue, task, language);
|
|
2250
|
+
const message = await terminalNotificationMessage(root, queue, task, language);
|
|
2188
2251
|
if (options.dryRun) {
|
|
2189
2252
|
results.push({ taskId: task.id, status: task.status, outcome: 'dry_run', message, source: task.source });
|
|
2190
2253
|
continue;
|
|
@@ -2215,6 +2278,7 @@ export async function notifyTerminalTasks(root, options = {}) {
|
|
|
2215
2278
|
result: compactCommandResult(result)
|
|
2216
2279
|
};
|
|
2217
2280
|
await writeJson(ledgerFile, ledger);
|
|
2281
|
+
await linkTerminalNotificationToRun(root, task, ledgerFile, ledger);
|
|
2218
2282
|
results.push({ taskId: task.id, status: task.status, outcome: 'sent', ledger: path.relative(root, ledgerFile) });
|
|
2219
2283
|
}
|
|
2220
2284
|
}
|
|
@@ -2280,7 +2344,7 @@ export async function refreshTaskAcceptance(root, options = {}) {
|
|
|
2280
2344
|
|
|
2281
2345
|
function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
2282
2346
|
const blockers = Array.isArray(checkpoint.blockers) ? checkpoint.blockers : [];
|
|
2283
|
-
const deferredGates =
|
|
2347
|
+
const deferredGates = materializableDeferredGates(checkpoint);
|
|
2284
2348
|
const requirements = blockers.length > 0 ? blockers : deferredGates;
|
|
2285
2349
|
const blockerText = requirements.length
|
|
2286
2350
|
? requirements.map((item) => typeof item === 'string' ? item : item?.required_authority ?? item?.human_action_required ?? item?.user_action ?? item?.reason ?? item?.description ?? item?.message ?? JSON.stringify(item))
|
|
@@ -2303,6 +2367,19 @@ function humanInputMessage(queue, task, checkpoint, gateId, language = 'en') {
|
|
|
2303
2367
|
].join('\n');
|
|
2304
2368
|
}
|
|
2305
2369
|
|
|
2370
|
+
function materializableDeferredGates(checkpoint) {
|
|
2371
|
+
const gates = Array.isArray(checkpoint?.deferred_gates) ? checkpoint.deferred_gates : [];
|
|
2372
|
+
return gates.filter((gate) => {
|
|
2373
|
+
if (!gate || typeof gate !== 'object' || Array.isArray(gate)) return false;
|
|
2374
|
+
const action = gate.action ?? gate.kind;
|
|
2375
|
+
const authority = gate.required_authority ?? gate.human_action_required;
|
|
2376
|
+
return typeof action === 'string' && action.trim().length > 0
|
|
2377
|
+
&& typeof authority === 'string' && authority.trim().length > 0
|
|
2378
|
+
&& gate.materialize !== false
|
|
2379
|
+
&& !['future', 'conditional', 'not_required'].includes(gate.state);
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2306
2383
|
async function tasksById(root, queue) {
|
|
2307
2384
|
const tasks = new Map();
|
|
2308
2385
|
for (const subdir of ['inbox', 'active', 'waiting', 'done', 'failed', 'canceled']) {
|
|
@@ -2341,7 +2418,7 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2341
2418
|
for (const file of files) {
|
|
2342
2419
|
const checkpointFile = path.join(runtimeDir, 'checkpoints', file);
|
|
2343
2420
|
const checkpoint = await readJson(checkpointFile);
|
|
2344
|
-
const deferred =
|
|
2421
|
+
const deferred = materializableDeferredGates(checkpoint);
|
|
2345
2422
|
if (['needs_human_input', 'blocked'].includes(checkpoint?.status) || deferred.length > 0) {
|
|
2346
2423
|
checkpoints.push({ file, checkpoint, mtimeMs: (await stat(checkpointFile)).mtimeMs });
|
|
2347
2424
|
}
|
|
@@ -2352,8 +2429,8 @@ async function authoritativeHumanGateCandidates(root, queue, tasks) {
|
|
|
2352
2429
|
|| String(b.checkpoint.checkpoint_id ?? b.file).localeCompare(String(a.checkpoint.checkpoint_id ?? a.file)));
|
|
2353
2430
|
const metadata = await gateProjectMetadata(root, entry.task, checkpoints[0].checkpoint);
|
|
2354
2431
|
const latestCheckpoint = checkpoints[0].checkpoint;
|
|
2355
|
-
const
|
|
2356
|
-
|
|
2432
|
+
const latestDeferredGates = materializableDeferredGates(latestCheckpoint);
|
|
2433
|
+
const deferredOnly = latestDeferredGates.length > 0
|
|
2357
2434
|
&& (!Array.isArray(latestCheckpoint.blockers) || latestCheckpoint.blockers.length === 0)
|
|
2358
2435
|
&& !['needs_human_input', 'blocked'].includes(latestCheckpoint.status);
|
|
2359
2436
|
const spec = specs.find((item) => item.project === metadata.project_id);
|
|
@@ -2389,7 +2466,7 @@ export async function notifyHumanInputRequests(root, options = {}) {
|
|
|
2389
2466
|
const results = [];
|
|
2390
2467
|
const candidates = await authoritativeHumanGateCandidates(root, queue, tasks);
|
|
2391
2468
|
for (const { taskId, entry, file, checkpoint } of candidates) {
|
|
2392
|
-
const deferredGates =
|
|
2469
|
+
const deferredGates = materializableDeferredGates(checkpoint);
|
|
2393
2470
|
const checkpointId = checkpoint.checkpoint_id ?? path.basename(file, '.json');
|
|
2394
2471
|
const gateId = `${taskId}:${checkpointId}`;
|
|
2395
2472
|
const ledgerFile = path.join(gatesDir, `${safeTaskId(taskId)}.${normalizeLoopId(checkpointId)}.json`);
|
|
@@ -2789,6 +2866,35 @@ function defaultBlockedActions(riskLevel) {
|
|
|
2789
2866
|
return blocked;
|
|
2790
2867
|
}
|
|
2791
2868
|
|
|
2869
|
+
function taskProjectReference(task, specs) {
|
|
2870
|
+
const explicit = String(task?.projectId ?? task?.project_id ?? '').trim();
|
|
2871
|
+
if (explicit) return specs.find((spec) => spec.project === explicit) ?? null;
|
|
2872
|
+
const text = `${task?.title ?? ''}\n${task?.body ?? ''}`;
|
|
2873
|
+
return specs
|
|
2874
|
+
.filter((spec) => typeof spec?.project === 'string' && text.includes(spec.project))
|
|
2875
|
+
.sort((a, b) => b.project.length - a.project.length)[0] ?? null;
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
async function taskProjectAuthorization(root, task) {
|
|
2879
|
+
const projectSpec = taskProjectReference(task, await projectSpecs(root));
|
|
2880
|
+
if (!projectSpec?.actionPolicy || typeof projectSpec.actionPolicy !== 'object') return null;
|
|
2881
|
+
const policy = projectSpec.actionPolicy;
|
|
2882
|
+
const standing = (value) => typeof value === 'string' && value.startsWith('standing_authorization_');
|
|
2883
|
+
const productionAuthorized = standing(policy.deploy)
|
|
2884
|
+
&& standing(policy.productionConfig)
|
|
2885
|
+
&& standing(policy.backupRestoreRollbackRehearsal);
|
|
2886
|
+
const paidLimit = Number(policy.paidActionPerActionCnyLimit);
|
|
2887
|
+
const paidAuthorized = Number.isFinite(paidLimit) && paidLimit > 0;
|
|
2888
|
+
return {
|
|
2889
|
+
project: projectSpec.project,
|
|
2890
|
+
source: `configs/loops/projects/${projectSpec.project}.json`,
|
|
2891
|
+
production_authorized: productionAuthorized,
|
|
2892
|
+
production_authorization: productionAuthorized ? policy.deploy : null,
|
|
2893
|
+
paid_action_authorized: paidAuthorized,
|
|
2894
|
+
paid_action_per_action_cny_limit: paidAuthorized ? paidLimit : null
|
|
2895
|
+
};
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2792
2898
|
export function inferTaskScope(task) {
|
|
2793
2899
|
const requestText = `${task?.title ?? ''}\n${task?.body ?? ''}`.toLowerCase();
|
|
2794
2900
|
// A project can be referenced as context while the routed task is explicitly
|
|
@@ -2808,6 +2914,18 @@ function buildTaskContract(queue, task, options = {}) {
|
|
|
2808
2914
|
const modelAssessed = task.riskAssessment === 'model_assessed';
|
|
2809
2915
|
const riskLevel = options.riskLevel ?? (modelAssessed ? 'model_assessed' : inferredRisk.level);
|
|
2810
2916
|
const taskScope = inferTaskScope(task);
|
|
2917
|
+
const projectAuthorization = options.projectAuthorization ?? null;
|
|
2918
|
+
const blockedActions = defaultBlockedActions(riskLevel).filter((action) => !(
|
|
2919
|
+
projectAuthorization?.production_authorized
|
|
2920
|
+
&& action === 'production_config_change_without_explicit_confirmation'
|
|
2921
|
+
));
|
|
2922
|
+
const allowedActions = defaultAllowedActions(riskLevel);
|
|
2923
|
+
if (projectAuthorization?.production_authorized) {
|
|
2924
|
+
allowedActions.push('in_scope_production_deploy_config_backup_restore_rollback_under_standing_authorization');
|
|
2925
|
+
}
|
|
2926
|
+
if (projectAuthorization?.paid_action_authorized) {
|
|
2927
|
+
allowedActions.push('paid_provider_action_with_existing_credentials_within_per_action_cny_limit');
|
|
2928
|
+
}
|
|
2811
2929
|
return {
|
|
2812
2930
|
version: 1,
|
|
2813
2931
|
task_id: task.id,
|
|
@@ -2822,8 +2940,9 @@ function buildTaskContract(queue, task, options = {}) {
|
|
|
2822
2940
|
'Verification evidence or blockers'
|
|
2823
2941
|
],
|
|
2824
2942
|
constraints: {
|
|
2825
|
-
allowed_actions:
|
|
2826
|
-
blocked_actions:
|
|
2943
|
+
allowed_actions: allowedActions,
|
|
2944
|
+
blocked_actions: blockedActions,
|
|
2945
|
+
...(projectAuthorization ? { project_authorization: projectAuthorization } : {}),
|
|
2827
2946
|
workspace: options.workspace ?? null
|
|
2828
2947
|
},
|
|
2829
2948
|
risk_level: riskLevel,
|
|
@@ -2921,7 +3040,8 @@ export async function writeTaskContract(root, queue, task, options = {}) {
|
|
|
2921
3040
|
let contract = buildTaskContract(queue, task, {
|
|
2922
3041
|
...options,
|
|
2923
3042
|
workspace: options.workspace ?? root,
|
|
2924
|
-
historicalPatterns
|
|
3043
|
+
historicalPatterns,
|
|
3044
|
+
projectAuthorization: options.projectAuthorization ?? await taskProjectAuthorization(root, task)
|
|
2925
3045
|
});
|
|
2926
3046
|
contract = await mergeLiveAmendments(root, queue, task.id, contract, 'supplemental_requirements');
|
|
2927
3047
|
await writeJson(file, contract);
|
|
@@ -3150,8 +3270,7 @@ export async function writeAcceptancePlan(root, queue, task, taskContract, optio
|
|
|
3150
3270
|
};
|
|
3151
3271
|
}
|
|
3152
3272
|
|
|
3153
|
-
function buildDevPlan(contract, acceptancePlan) {
|
|
3154
|
-
const firstCheckpointId = 'cp1';
|
|
3273
|
+
function buildDevPlan(contract, acceptancePlan, firstCheckpointId = 'cp1') {
|
|
3155
3274
|
const gated = contract.requires_human_gate;
|
|
3156
3275
|
return {
|
|
3157
3276
|
version: 1,
|
|
@@ -3194,6 +3313,10 @@ function buildDevPlan(contract, acceptancePlan) {
|
|
|
3194
3313
|
verification: [],
|
|
3195
3314
|
blockers: [],
|
|
3196
3315
|
deferred_gates: [],
|
|
3316
|
+
deferred_gate_schema: {
|
|
3317
|
+
action: 'Concrete action that cannot proceed now.',
|
|
3318
|
+
required_authority: 'Specific authority or input required for that action.'
|
|
3319
|
+
},
|
|
3197
3320
|
project_completion: null,
|
|
3198
3321
|
risks: [],
|
|
3199
3322
|
next_action: 'acceptance_review'
|
|
@@ -3209,7 +3332,14 @@ export async function writeDevPlan(root, queue, task, taskContract, acceptancePl
|
|
|
3209
3332
|
await mkdir(checkpointsDir, { recursive: true });
|
|
3210
3333
|
await mkdir(reviewsDir, { recursive: true });
|
|
3211
3334
|
const file = path.join(dir, 'dev_plan.json');
|
|
3212
|
-
|
|
3335
|
+
const existingCheckpointFiles = await listJson(checkpointsDir);
|
|
3336
|
+
const usedCheckpointNumbers = new Set(existingCheckpointFiles
|
|
3337
|
+
.map((file) => Number(String(file).match(/^cp(\d+)\.json$/)?.[1]))
|
|
3338
|
+
.filter((value) => Number.isInteger(value) && value > 0));
|
|
3339
|
+
let checkpointNumber = 1;
|
|
3340
|
+
while (usedCheckpointNumbers.has(checkpointNumber)) checkpointNumber += 1;
|
|
3341
|
+
const nextCheckpointId = `cp${checkpointNumber}`;
|
|
3342
|
+
let plan = buildDevPlan(taskContract.contract, acceptancePlan.plan, nextCheckpointId);
|
|
3213
3343
|
plan = await mergeLiveAmendments(root, queue, task.id, plan, 'supplemental_instructions');
|
|
3214
3344
|
await writeJson(file, plan);
|
|
3215
3345
|
return {
|
|
@@ -3490,7 +3620,7 @@ export async function writeAcceptanceReviews(root, queue, task, taskContract, ac
|
|
|
3490
3620
|
checkpointCreatedAt: checkpoint.created_at ?? null,
|
|
3491
3621
|
createdAt: checkpoint.created_at ?? review.created_at,
|
|
3492
3622
|
projectCompletion: checkpoint.project_completion ?? null,
|
|
3493
|
-
deferredGates:
|
|
3623
|
+
deferredGates: materializableDeferredGates(checkpoint),
|
|
3494
3624
|
status: review.status,
|
|
3495
3625
|
file: path.relative(root, reviewFile)
|
|
3496
3626
|
});
|
package/package.json
CHANGED
|
@@ -33,6 +33,7 @@ const install = await run(process.execPath, [installer, ...base, '--confirm-inst
|
|
|
33
33
|
const queue = JSON.parse(await readFile(path.join(root, 'configs/loops/queues/hermes-tasks.json'), 'utf8')); if (queue.dispatcher !== 'node scripts/loops/hermes-loop-dispatch.mjs' || queue.scheduler?.required !== true) throw new Error('Hermes queue wiring missing');
|
|
34
34
|
const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8'); if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('Hermes English conversation instructions are missing or contain Chinese routing examples');
|
|
35
35
|
const wrapper = await readFile(path.join(root, 'scripts/loops/hermes-loop.mjs'), 'utf8'); if (!wrapper.includes('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper)) throw new Error('Hermes English wrapper routing was not fully localized');
|
|
36
|
+
if (!wrapper.includes('process.exitCode = tickCode;') || wrapper.includes('process.exitCode = tickCode || humanCode || terminalCode;')) throw new Error('Hermes scheduler health still depends on notification delivery');
|
|
36
37
|
const dispatcher = await readFile(path.join(root, 'scripts/loops/hermes-loop-dispatch.mjs'), 'utf8'); if (!dispatcher.includes("'-z', prompt")) throw new Error('Hermes one-shot dispatcher missing');
|
|
37
38
|
const notifier = path.join(root, 'scripts/loops/hermes-loop-notify.mjs'); const notify = await run(process.execPath, [notifier, 'hello from loop'], { env: { ...process.env, HERMES_SEND_CAPTURE: sendCapture, LOOP_NOTIFICATION_SOURCE: JSON.stringify({ channel: 'telegram', target: 'telegram:12345' }) } }); if (notify.code !== 0) throw new Error(`Hermes notifier failed: ${notify.stderr}`);
|
|
38
39
|
const sent = JSON.parse(await readFile(sendCapture, 'utf8')); if (!sent.includes('telegram:12345') || !sent.includes('hello from loop')) throw new Error('Hermes notifier did not preserve delivery target/message');
|
|
@@ -116,7 +116,7 @@ if (command === 'route') {
|
|
|
116
116
|
const tickCode = await run(['queue-scheduler-tick', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/hermes-loop-notify.mjs', ...rest]);
|
|
117
117
|
const humanCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
118
118
|
const terminalCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/hermes-loop-notify.mjs']);
|
|
119
|
-
process.exitCode = tickCode
|
|
119
|
+
process.exitCode = tickCode;
|
|
120
120
|
} else { console.error(${JSON.stringify(text(language, 'Usage: node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick', '用法:node scripts/loops/hermes-loop.mjs route|run-once|scheduler-tick'))}); process.exitCode = 1; }
|
|
121
121
|
`;
|
|
122
122
|
}
|
|
@@ -70,6 +70,7 @@ const instructions = await readFile(path.join(root, 'AGENTS.md'), 'utf8');
|
|
|
70
70
|
if (!instructions.includes('Use Loop Engineering to fix this issue') || !instructions.includes('Queue this only; do not run it yet') || /走 loop|只入队|只排队/.test(instructions)) throw new Error('English conversation instructions are missing or contain Chinese routing examples');
|
|
71
71
|
const wrapper = await readFile(path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'utf8');
|
|
72
72
|
if (!wrapper.includes('--supersede-active') || !wrapper.includes('--amend-active') || !wrapper.includes('--progress-notify-command') || !wrapper.includes('runWhenUnlocked') || wrapper.includes("run-queue-drain', '--config'") || wrapper.includes("spawn('loop-engineering'") || !wrapper.includes('queue-human-input-notify') || !wrapper.includes('queue-terminal-notify') || !wrapper.includes('queue-scheduler-tick') || !wrapper.includes('queue') || !wrapper.includes('continue') || /只入队|只排队|继续当前/.test(wrapper) || !wrapper.includes('requiredSource') || !wrapper.includes('--source-message-id')) throw new Error('supersede/amend routing, source fail-closed policy, localization, absolute CLI, scheduler, live progress, async notification, or queue-only routing missing');
|
|
73
|
+
if (!wrapper.includes('process.exitCode = tickCode;') || wrapper.includes('process.exitCode = tickCode || humanNotifyCode || terminalNotifyCode;')) throw new Error('scheduler health still depends on notification delivery');
|
|
73
74
|
const missingSourceRoute = await new Promise((resolve) => {
|
|
74
75
|
const child = spawn(process.execPath, [path.join(root, 'scripts/loops/openclaw-loop.mjs'), 'route', '--message', '用 loop engineering 对齐系统'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
75
76
|
let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
@@ -218,7 +218,7 @@ if (command === 'route') {
|
|
|
218
218
|
const tickCode = await run(['queue-scheduler-tick', '--config', ${JSON.stringify(`configs/loops/queues/${queue}.json`)}, '--progress-notify-command', 'node scripts/loops/openclaw-loop-notify.mjs', ...rest]);
|
|
219
219
|
const humanNotifyCode = await run(['queue-human-input-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
220
220
|
const terminalNotifyCode = await run(['queue-terminal-notify', '--queue', ${JSON.stringify(queue)}, '--notify-command', 'node scripts/loops/openclaw-loop-notify.mjs']);
|
|
221
|
-
process.exitCode = tickCode
|
|
221
|
+
process.exitCode = tickCode;
|
|
222
222
|
} else {
|
|
223
223
|
console.error(${JSON.stringify(usage)});
|
|
224
224
|
process.exitCode = 1;
|
|
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import { mkdtemp, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, routeLoopMessage, taskRuntimeDirFor } from '../lib/core.mjs';
|
|
5
|
+
import { doctorReport, enqueueTask, notifyHumanInputRequests, projectStatus, queueStatus, reconcileProjectGates, routeLoopMessage, taskRuntimeDirFor, writeTaskContract } from '../lib/core.mjs';
|
|
6
6
|
|
|
7
7
|
const root = await mkdtemp(path.join(os.tmpdir(), 'loop-project-gates-'));
|
|
8
8
|
const queue = 'shared';
|
|
@@ -14,6 +14,32 @@ const spec = { schemaVersion: 1, project: 'openreel', type: 'code_project', queu
|
|
|
14
14
|
await mkdir(path.dirname(projectFile), { recursive: true });
|
|
15
15
|
await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
16
16
|
|
|
17
|
+
// A project standing authorization must reach the generated task contract.
|
|
18
|
+
// It authorizes only the configured in-scope production and bounded paid
|
|
19
|
+
// actions; unrelated external, destructive, credential, push and publish
|
|
20
|
+
// actions remain blocked.
|
|
21
|
+
await writeFile(projectFile, `${JSON.stringify({
|
|
22
|
+
...spec,
|
|
23
|
+
actionPolicy: {
|
|
24
|
+
deploy: 'standing_authorization_openreel_2026-08-19',
|
|
25
|
+
productionConfig: 'standing_authorization_openreel_2026-08-19',
|
|
26
|
+
backupRestoreRollbackRehearsal: 'standing_authorization_openreel_2026-08-19',
|
|
27
|
+
paidActionPerActionCnyLimit: 100,
|
|
28
|
+
externalWrites: 'human_confirm',
|
|
29
|
+
destructiveActions: 'human_confirm'
|
|
30
|
+
}
|
|
31
|
+
}, null, 2)}\n`);
|
|
32
|
+
const authorizedTask = await enqueueTask(root, { queue, title: 'OpenReel production acceptance', task: 'Continue project openreel production acceptance under its action policy', projectId: 'openreel' });
|
|
33
|
+
const authorizedContract = (await writeTaskContract(root, queue, authorizedTask.task)).contract;
|
|
34
|
+
assert.equal(authorizedContract.constraints.project_authorization.project, 'openreel');
|
|
35
|
+
assert.equal(authorizedContract.constraints.project_authorization.production_authorized, true);
|
|
36
|
+
assert.equal(authorizedContract.constraints.project_authorization.paid_action_per_action_cny_limit, 100);
|
|
37
|
+
assert.equal(authorizedContract.constraints.blocked_actions.includes('production_config_change_without_explicit_confirmation'), false);
|
|
38
|
+
assert(authorizedContract.constraints.allowed_actions.includes('in_scope_production_deploy_config_backup_restore_rollback_under_standing_authorization'));
|
|
39
|
+
assert(authorizedContract.constraints.allowed_actions.includes('paid_provider_action_with_existing_credentials_within_per_action_cny_limit'));
|
|
40
|
+
assert(authorizedContract.constraints.blocked_actions.includes('credential_change_without_explicit_confirmation'));
|
|
41
|
+
await writeFile(projectFile, `${JSON.stringify(spec, null, 2)}\n`);
|
|
42
|
+
|
|
17
43
|
const b = await enqueueTask(root, { queue, title: 'OpenReel B-01', task: 'Project openreel requirement B-01', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
18
44
|
const checkpointDir = path.join(taskRuntimeDirFor(root, queue, b.task.id), 'checkpoints');
|
|
19
45
|
await mkdir(checkpointDir, { recursive: true });
|
|
@@ -85,6 +111,21 @@ assert.equal(deferredGate.authorization_requirements[0].action, 'production_roll
|
|
|
85
111
|
assert.match(deferredGate.authorization_requirements[0].required_authority, /Owner authorization/);
|
|
86
112
|
assert.equal((await queueStatus(root, queue)).waiting >= 1, true);
|
|
87
113
|
|
|
114
|
+
// Conditional policy boundaries are not current blockers. Plain prose in
|
|
115
|
+
// deferred_gates must not materialize a gate without a concrete action and
|
|
116
|
+
// authority requirement.
|
|
117
|
+
await reconcileProjectGates(root, { queue });
|
|
118
|
+
const conditional = await enqueueTask(root, { queue, title: 'OpenReel conditional policy boundary', task: 'Continue safe OpenReel backlog', projectId: 'openreel', sourceChannel: 'test', sourceTarget: 'owner' });
|
|
119
|
+
const conditionalDir = path.join(taskRuntimeDirFor(root, queue, conditional.task.id), 'checkpoints');
|
|
120
|
+
await mkdir(conditionalDir, { recursive: true });
|
|
121
|
+
await writeFile(path.join(conditionalDir, 'cp1.json'), `${JSON.stringify({
|
|
122
|
+
version: 1, task_id: conditional.task.id, checkpoint_id: 'cp1', status: 'ready_for_acceptance', blockers: [],
|
|
123
|
+
project_completion: { status: 'in_progress' },
|
|
124
|
+
deferred_gates: ['Any future paid action above the standing limit requires confirmation.']
|
|
125
|
+
}, null, 2)}\n`);
|
|
126
|
+
const conditionalNotice = await notifyHumanInputRequests(root, { queue, notifyCommand: '/bin/true' });
|
|
127
|
+
assert.equal(conditionalNotice.results.some((item) => item.taskId === conditional.task.id), false);
|
|
128
|
+
|
|
88
129
|
// Once the authoritative project ledger accepts the terminal contract, an
|
|
89
130
|
// optional post-completion deferred action stays in operations backlog and
|
|
90
131
|
// neither creates nor retains a project-queue waiting gate.
|
|
@@ -104,4 +145,4 @@ const acceptedNotice = await notifyHumanInputRequests(root, { queue, notifyComma
|
|
|
104
145
|
assert.equal(acceptedNotice.results.some((item) => item.taskId === acceptedOptional.task.id), false);
|
|
105
146
|
assert.equal((await queueStatus(root, queue)).waiting, 0);
|
|
106
147
|
|
|
107
|
-
console.log(JSON.stringify({ status: 'ok', assertions: ['structured gate context', 'B-01 waiting to zero', 'project-isolated supersede', 'doctor strong validation', 'authoritative ledger drift', 'ready milestone deferred gate', 'accepted project optional deferred gate stays out of queue'] }));
|
|
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'] }));
|
|
@@ -20,12 +20,25 @@ import {
|
|
|
20
20
|
runQueueOnce,
|
|
21
21
|
runQueueDrain,
|
|
22
22
|
taskRuntimeDirFor,
|
|
23
|
+
writeDevPlan,
|
|
23
24
|
writeJson
|
|
24
25
|
} from '../lib/core.mjs';
|
|
25
26
|
|
|
26
27
|
const root = await mkdtemp(path.join(tmpdir(), 'loop-route-notify-'));
|
|
27
28
|
const queue = 'route-smoke';
|
|
28
29
|
|
|
30
|
+
// Replanning the same durable task must never point a worker at an existing
|
|
31
|
+
// checkpoint file. Historical checkpoint evidence is append-only.
|
|
32
|
+
const checkpointTask = { id: 'checkpoint-sequence' };
|
|
33
|
+
const checkpointContract = { contract: { task_id: checkpointTask.id, risk_level: 'L1', requires_human_gate: false } };
|
|
34
|
+
const checkpointAcceptance = { plan: { functional_checks: [], regression_checks: [], negative_tests: [] } };
|
|
35
|
+
const firstDevPlan = await writeDevPlan(root, queue, checkpointTask, checkpointContract, checkpointAcceptance);
|
|
36
|
+
assert.equal(firstDevPlan.plan.checkpoints[0].id, 'cp1');
|
|
37
|
+
await writeJson(path.join(taskRuntimeDirFor(root, queue, checkpointTask.id), 'checkpoints', 'cp1.json'), { checkpoint_id: 'cp1' });
|
|
38
|
+
const secondDevPlan = await writeDevPlan(root, queue, checkpointTask, checkpointContract, checkpointAcceptance);
|
|
39
|
+
assert.equal(secondDevPlan.plan.checkpoints[0].id, 'cp2');
|
|
40
|
+
assert.equal(secondDevPlan.plan.checkpoint_schema.checkpoint_id, 'cp2');
|
|
41
|
+
|
|
29
42
|
assert.equal(normalizeGoalDecision({ verdict: 'revise' }).decision, 'change_strategy');
|
|
30
43
|
assert.deepEqual(goalLoopTransition({ decision: 'change_strategy' }, { round: 1, maxRounds: 3 }), {
|
|
31
44
|
status: 'replan_pending',
|
|
@@ -495,12 +508,27 @@ assert.deepEqual(await readJson(activeRequeued), activeBefore);
|
|
|
495
508
|
await rename(activeRequeued, inboxRequeued);
|
|
496
509
|
await writeJson(failedFile, { ...requeuedTask, status: 'needs_human_input' });
|
|
497
510
|
await rm(inboxRequeued, { force: true });
|
|
511
|
+
await writeJson(path.join(taskRuntimeDirFor(root, queue, routed.task.id), 'checkpoints', 'cp-terminal.json'), {
|
|
512
|
+
version: 1, task_id: routed.task.id, checkpoint_id: 'cp-terminal', sequence: 99,
|
|
513
|
+
status: 'ready_for_acceptance', summary: 'Terminal summary is visible.', blockers: [],
|
|
514
|
+
verification: [{ command: 'self-test', result: 'passed' }], risks: [], next_action: 'report'
|
|
515
|
+
});
|
|
516
|
+
await writeJson(finalJudgementFile, {
|
|
517
|
+
version: 1, task_id: routed.task.id, outcome: 'ready_to_apply', next_actions: ['Report the accepted result.']
|
|
518
|
+
});
|
|
498
519
|
|
|
499
520
|
const dryRun = await notifyTerminalTasks(root, { queue, dryRun: true });
|
|
500
521
|
assert.match(dryRun.results[0].message, /Loop 任务/);
|
|
522
|
+
assert.match(dryRun.results[0].message, /最终判定:/);
|
|
523
|
+
assert.match(dryRun.results[0].message, /结果:/);
|
|
501
524
|
assert.equal(dryRun.results[0].outcome, 'dry_run');
|
|
502
525
|
const sent = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
|
|
503
526
|
assert.equal(sent.sent, 1);
|
|
527
|
+
const notifiedTask = await readJson(path.join(queueSubdirFor(root, queue, 'failed'), path.basename(failedFile)));
|
|
528
|
+
if (notifiedTask.runPath) {
|
|
529
|
+
const notifiedRun = await readJson(path.join(root, notifiedTask.runPath));
|
|
530
|
+
assert.equal(notifiedRun.terminalNotification?.outcome, 'sent');
|
|
531
|
+
}
|
|
504
532
|
const repeated = await notifyTerminalTasks(root, { queue, notifyCommand: '/bin/true' });
|
|
505
533
|
assert.equal(repeated.results[0].outcome, 'already_notified');
|
|
506
534
|
|
|
@@ -519,7 +547,7 @@ for (const [id, enqueuedAt] of [['history-done', '2026-01-01T00:00:00Z'], ['late
|
|
|
519
547
|
});
|
|
520
548
|
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'checkpoints', 'cp1.json'), {
|
|
521
549
|
version: 1, task_id: id, checkpoint_id: 'cp1', milestone_id: 'R-1', sequence: 1,
|
|
522
|
-
status: 'ready_for_acceptance', blockers: [], deferred_gates: [{ id: 'R-1', required_authority: 'Approve R-1.' }],
|
|
550
|
+
status: 'ready_for_acceptance', blockers: [], deferred_gates: [{ id: 'R-1', action: 'approve_requirement', required_authority: 'Approve R-1.' }],
|
|
523
551
|
verification: [], risks: [], project_completion: 'in_progress', next_action: 'wait'
|
|
524
552
|
});
|
|
525
553
|
await writeJson(path.join(taskRuntimeDirFor(regressionRoot, regressionQueue, id), 'final_judgement.json'), {
|