feat: route force-recall continuity via plugin adapter

This commit is contained in:
Eve
2026-04-24 17:26:50 +08:00
parent b336958fc0
commit acf83824b7
8 changed files with 486 additions and 10 deletions

View File

@@ -1,7 +1,63 @@
export function createForceRecallContinuityAdapter() {
throw new Error('Not implemented: force-recall continuity adapter contract placeholder');
import { evaluateContinuity, buildContinuityGateBlock } from '../continuity/evaluator.mjs';
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
export function runForceRecallContinuityAdapter() {
throw new Error('Not implemented: force-recall continuity adapter contract placeholder');
export function buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult = null) {
if (!wrapperResult || wrapperResult.classification !== 'long_task') return null;
const wrapperNextAction = wrapperResult?.nextDerivedAction ?? wrapperResult?.derivedAction ?? null;
const plannerDerivedAction = autoChainPlanResult?.derivedAction && autoChainPlanResult.derivedAction !== 'none'
? {
type: autoChainPlanResult.dispatchMode ?? 'no_dispatch',
action: autoChainPlanResult.derivedAction,
}
: null;
const nextDerivedAction = wrapperNextAction ?? plannerDerivedAction;
if (nextDerivedAction == null) return null;
const replyClosureState = isNonEmptyString(wrapperResult?.replyClosureState)
? wrapperResult.replyClosureState
: (wrapperResult?.handoff?.mode === 'button_path' ? 'waiting_user' : 'completed');
const dispatchReceipt = wrapperResult?.dispatchReceipt ?? null;
const nextTaskKnown = wrapperResult?.nextTaskKnown === true
|| (plannerDerivedAction != null && isNonEmptyString(autoChainPlanResult?.derivedAction) && autoChainPlanResult.derivedAction !== 'none');
const sameApprovedPlan = wrapperResult?.sameApprovedPlan === true || plannerDerivedAction != null;
const taskBoundaryStop = wrapperResult?.taskBoundaryStop === true || replyClosureState === 'completed';
const highRiskStop = wrapperResult?.highRiskStop === true;
return {
planId: wrapperResult?.planId ?? 'hook-preflight-approved-plan',
currentTask: wrapperResult?.currentTask ?? wrapperResult?.requiredNextAction ?? 'hook-preflight-task',
taskState: wrapperResult?.taskState ?? (plannerDerivedAction ? 'complete' : null),
nextDerivedAction,
replyClosureState,
dispatchReceipt,
nextTaskKnown,
sameApprovedPlan,
taskBoundaryStop,
highRiskStop,
};
}
export function createForceRecallContinuityAdapter(config = {}) {
const legalTerminalStates = config?.legalTerminalStates;
const label = config?.adapter?.forceRecall?.injectBlockLabel ?? 'APPROVED_PLAN_CONTINUITY_GATE';
return {
evaluate({ wrapperResult, autoChainPlanResult = null }) {
const input = buildApprovedPlanContinuityInput(wrapperResult, autoChainPlanResult);
if (!input) return { input: null, result: null, block: '' };
const result = evaluateContinuity(input, { legalTerminalStates });
const block = buildContinuityGateBlock(result, { legalTerminalStates, label });
return { input, result, block };
},
};
}
export function runForceRecallContinuityAdapter({ wrapperResult, autoChainPlanResult = null, config = {} } = {}) {
return createForceRecallContinuityAdapter(config).evaluate({ wrapperResult, autoChainPlanResult });
}

View File

@@ -1,7 +1,120 @@
export function evaluateContinuity() {
throw new Error('Not implemented: continuity evaluator contract placeholder');
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function buildContinuityGateBlock() {
throw new Error('Not implemented: continuity gate block contract placeholder');
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function normalizeAction(action) {
return JSON.stringify(action ?? null);
}
export function hasValidDispatchReceipt(receipt) {
if (!isPlainObject(receipt)) return false;
if (!isNonEmptyString(receipt.planId)) return false;
if (!isNonEmptyString(receipt.currentTask)) return false;
if (!isPlainObject(receipt.nextDerivedAction)) return false;
if (!isNonEmptyString(receipt.dispatchedAt)) return false;
return true;
}
export function receiptMatchesPayload(payload, receipt) {
if (!hasValidDispatchReceipt(receipt)) return false;
const expectedPlanId = payload?.planId;
if (isNonEmptyString(expectedPlanId) && receipt.planId !== expectedPlanId) return false;
const expectedCurrentTask = payload?.currentTask;
if (isNonEmptyString(expectedCurrentTask) && receipt.currentTask !== expectedCurrentTask) return false;
const expectedNextTask = payload?.nextTaskId ?? payload?.nextTaskKey ?? null;
const receiptNextTask = receipt?.nextTaskId ?? receipt?.nextTaskKey ?? null;
if (isNonEmptyString(expectedNextTask) && receiptNextTask !== expectedNextTask) return false;
const expectedNextAction = payload?.nextDerivedAction ?? payload?.derivedAction ?? null;
if (expectedNextAction != null && normalizeAction(receipt.nextDerivedAction) !== normalizeAction(expectedNextAction)) {
return false;
}
return true;
}
export function evaluateContinuity(payload, options = {}) {
const legalTerminalStates = new Set(options.legalTerminalStates ?? ['waiting_user', 'blocked', 'pending_verification']);
const taskComplete = payload?.taskState === 'complete';
const nextAction = payload?.nextDerivedAction ?? payload?.derivedAction ?? null;
const nextActionKnown = nextAction != null;
const explicitNextTaskKnown = payload?.nextTaskKnown === true;
const sameApprovedPlan = payload?.sameApprovedPlan === true;
const taskBoundaryStop = payload?.taskBoundaryStop === true;
const highRiskStop = payload?.highRiskStop === true;
const closureState = payload?.replyClosureState ?? null;
const isLegalTerminalState = legalTerminalStates.has(closureState);
const hasDispatchReceipt = receiptMatchesPayload(payload, payload?.dispatchReceipt ?? null);
const autoNextObligatory = taskComplete
&& explicitNextTaskKnown
&& sameApprovedPlan
&& taskBoundaryStop
&& !isLegalTerminalState
&& !highRiskStop;
if (autoNextObligatory && !hasDispatchReceipt) {
return {
ok: false,
status: 'continuity_failure',
verdict: 'continuity_failure',
reason: 'missing_auto_next_dispatch',
};
}
if (taskComplete && nextActionKnown && !hasDispatchReceipt && !isLegalTerminalState && !highRiskStop && !('sameApprovedPlan' in (payload ?? {}))) {
return {
ok: false,
status: 'continuity_failure',
verdict: 'continuity_failure',
reason: 'missing_dispatch_receipt',
};
}
if (taskComplete && nextActionKnown && !hasDispatchReceipt && !isLegalTerminalState && !highRiskStop && sameApprovedPlan && !taskBoundaryStop && !explicitNextTaskKnown) {
return {
ok: false,
status: 'continuity_failure',
verdict: 'continuity_failure',
reason: 'missing_dispatch_receipt',
};
}
return {
ok: true,
status: 'pass',
verdict: 'pass',
};
}
export function buildContinuityGateBlock(result, options = {}) {
if (!result) return '';
const label = isNonEmptyString(options.label) ? options.label.trim() : 'APPROVED_PLAN_CONTINUITY_GATE';
const terminalStates = options.legalTerminalStates ?? ['waiting_user', 'blocked', 'pending_verification'];
const lines = [
`[${label}]`,
`status=${result.status}`,
`verdict=${result.verdict}`,
];
if (result.reason) lines.push(`reason=${result.reason}`);
if (result.ok === false) {
lines.push('- HARD_GATE: Do not close out this reply as normal completion.');
if (result.reason === 'missing_auto_next_dispatch') {
lines.push('- HARD_GATE: Do not stop at this completed-task boundary.');
lines.push(`- HARD_GATE: Auto-dispatch the next task in the same approved plan, unless ${terminalStates.join(', ')}, or high-risk stop applies.`);
} else {
lines.push(`- HARD_GATE: Route back to continuity failure until a real next dispatch receipt exists, unless closure state is ${terminalStates.join(', ')}.`);
}
}
lines.push(`[/${label}]`, '');
return lines.join('\n');
}

View File

@@ -7,12 +7,20 @@ import {
import {
evaluateContinuity,
buildContinuityGateBlock,
hasValidDispatchReceipt,
receiptMatchesPayload,
} from './continuity/evaluator.mjs';
import {
validateReceipt,
isValidReceipt,
} from './continuity/receipt-validator.mjs';
import {
slugifyReceiptSegment,
buildReceiptFilename,
writeReceipt,
} from './continuity/receipt-store.mjs';
import {
buildApprovedPlanContinuityInput,
createForceRecallContinuityAdapter,
runForceRecallContinuityAdapter,
} from './adapters/force-recall.mjs';
@@ -25,8 +33,14 @@ export {
normalizeContinuityConfig,
evaluateContinuity,
buildContinuityGateBlock,
hasValidDispatchReceipt,
receiptMatchesPayload,
validateReceipt,
isValidReceipt,
slugifyReceiptSegment,
buildReceiptFilename,
writeReceipt,
buildApprovedPlanContinuityInput,
createForceRecallContinuityAdapter,
runForceRecallContinuityAdapter,
};
@@ -39,8 +53,14 @@ export default {
normalizeContinuityConfig,
evaluateContinuity,
buildContinuityGateBlock,
hasValidDispatchReceipt,
receiptMatchesPayload,
validateReceipt,
isValidReceipt,
slugifyReceiptSegment,
buildReceiptFilename,
writeReceipt,
buildApprovedPlanContinuityInput,
createForceRecallContinuityAdapter,
runForceRecallContinuityAdapter,
};

View File

@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import {
buildContinuityGateBlock,
evaluateContinuity,
hasValidDispatchReceipt,
receiptMatchesPayload,
} from '../src/continuity/evaluator.mjs';
function test(name, fn) {
try {
fn();
console.log(`ok - ${name}`);
} catch (error) {
console.error(`not ok - ${name}`);
throw error;
}
}
const validReceipt = {
planId: 'plan-1',
currentTask: 'task-7',
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
dispatchedAt: '2026-04-24T09:00:00.000Z',
};
test('recognizes minimum valid dispatch receipt', () => {
assert.equal(hasValidDispatchReceipt(validReceipt), true);
});
test('matches payload against valid receipt', () => {
const payload = {
planId: 'plan-1',
currentTask: 'task-7',
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
};
assert.equal(receiptMatchesPayload(payload, validReceipt), true);
});
test('fails when completion has next action and no receipt', () => {
const result = evaluateContinuity({
planId: 'plan-1',
currentTask: 'task-7',
taskState: 'complete',
nextDerivedAction: { type: 'message_subagent' },
replyClosureState: 'completed',
dispatchReceipt: null,
});
assert.equal(result.ok, false);
assert.equal(result.reason, 'missing_dispatch_receipt');
});
test('fails auto-next boundary without dispatch', () => {
const result = evaluateContinuity({
planId: 'plan-1',
currentTask: 'task-8',
taskState: 'complete',
nextTaskKnown: true,
sameApprovedPlan: true,
taskBoundaryStop: true,
nextTaskId: 'task-9',
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
replyClosureState: 'completed',
dispatchReceipt: null,
});
assert.equal(result.ok, false);
assert.equal(result.reason, 'missing_auto_next_dispatch');
});
test('passes allowed terminal state', () => {
const result = evaluateContinuity({
planId: 'plan-1',
currentTask: 'task-7',
taskState: 'complete',
nextDerivedAction: { type: 'message_subagent' },
replyClosureState: 'waiting_user',
dispatchReceipt: null,
});
assert.equal(result.ok, true);
});
test('renders hard-gate block', () => {
const text = buildContinuityGateBlock({
ok: false,
status: 'continuity_failure',
verdict: 'continuity_failure',
reason: 'missing_dispatch_receipt',
});
assert.match(text, /APPROVED_PLAN_CONTINUITY_GATE/);
assert.match(text, /HARD_GATE/);
});
console.log('continuity.evaluator.test.mjs PASS');

View File

@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import plugin, {
createForceRecallContinuityAdapter,
defaultConfig,
evaluateContinuity,
} from '../src/index.mjs';
function test(name, fn) {
try {
fn();
console.log(`ok - ${name}`);
} catch (error) {
console.error(`not ok - ${name}`);
throw error;
}
}
test('index exports plugin surface', () => {
assert.equal(plugin.name, '@openclaw/plugin-continuity');
assert.equal(typeof evaluateContinuity, 'function');
assert.equal(defaultConfig.adapter.forceRecall.enabled, true);
});
test('adapter preserves current hook parity for plain wrapper next-action mapping', () => {
const adapter = createForceRecallContinuityAdapter(defaultConfig);
const out = adapter.evaluate({
wrapperResult: {
classification: 'long_task',
planId: 'plan-1',
currentTask: 'task-7',
taskState: 'complete',
nextDerivedAction: { type: 'message_subagent', task: 'continue' },
replyClosureState: 'completed',
dispatchReceipt: null,
},
});
assert.equal(out.result.ok, true);
assert.match(out.block, /status=pass/);
});
test('adapter fails when planner-derived auto-next boundary exists without dispatch receipt', () => {
const adapter = createForceRecallContinuityAdapter(defaultConfig);
const out = adapter.evaluate({
wrapperResult: {
classification: 'long_task',
planId: 'plan-2',
currentTask: 'task-8',
replyClosureState: 'completed',
dispatchReceipt: null,
},
autoChainPlanResult: {
derivedAction: 'continue_task_9',
dispatchMode: 'message_subagent',
},
});
assert.equal(out.result.ok, false);
assert.equal(out.result.reason, 'missing_auto_next_dispatch');
assert.match(out.block, /continuity_failure/);
});
console.log('continuity.plugin.test.mjs PASS');

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import plugin, {
runForceRecallContinuityAdapter,
validateContinuityConfig,
} from '../src/index.mjs';
const configResult = validateContinuityConfig(plugin.defaultConfig);
assert.equal(configResult.ok, true);
const smoke = runForceRecallContinuityAdapter({
config: plugin.defaultConfig,
wrapperResult: {
classification: 'long_task',
planId: 'plan-smoke',
currentTask: 'task-8',
replyClosureState: 'completed',
dispatchReceipt: null,
},
autoChainPlanResult: {
derivedAction: 'continue_task_9',
dispatchMode: 'message_subagent',
},
});
assert.equal(smoke.result.ok, false);
assert.equal(smoke.result.reason, 'missing_auto_next_dispatch');
assert.match(smoke.block, /APPROVED_PLAN_CONTINUITY_GATE/);
console.log('continuity.smoke.test.mjs PASS');