fix: restore current channel and host admission organs
This commit is contained in:
parent
88ffb1c97a
commit
f46eb1b7c1
54 changed files with 4499 additions and 8 deletions
63
server-tools/dark-core/README.md
Normal file
63
server-tools/dark-core/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# 暗核任务控制器
|
||||
|
||||
暗核承接已确认目标并推进实际操作。当前主控人格体负责理解、技术判断和建议取舍;此控制器保存计划与回执,执行其调度,并保留人类停止、撤权和纠错入口。
|
||||
|
||||
路径:第五域 → 冰朔通感语言核系统 → 暗域系统 → 暗核频道(现实频道,`ICE-CH-DK001`)。
|
||||
|
||||
语义源:[TCS-DARK-CORE-EXECUTION-0001](../../bingshuo-tcs/dark-domain/dark-core/TCS-DARK-CORE-EXECUTION-0001.tcs)。该文件及其GIR是本工程线合同,不是世界协议升级,也不是现实授权票据。
|
||||
|
||||
## 当前可运行范围
|
||||
|
||||
- `task-controller.mjs`:固定计划、逐步授权与执行、实际结果验证、建议队列、暂停、停止、撤权和本地状态回执。
|
||||
- `local-file-host.mjs`:真实本地文件适配器,只允许确认计划里明确列出的文件,在指定目录直接创建文本文件;不覆盖既有文件,不运行shell,不连接服务器。
|
||||
- `demo.mjs`:在新的临时目录执行两步文件任务,中途建议被说明理由后延后,最终读回文件内容与哈希。
|
||||
- 频道已接入本地编号、世界树和脑运行器的显式频道解析。
|
||||
|
||||
尚未部署服务器,尚未接管Codex消息或原生工具调用。后续宿主必须把已验证的直接人类输入和已经原生授权的操作交给下述接口。库不能通过字段声明、频道名称或本地JSON自授权限。
|
||||
|
||||
## 运行
|
||||
|
||||
```sh
|
||||
node --test server-tools/dark-core/task-controller.test.mjs
|
||||
node server-tools/dark-core/demo.mjs
|
||||
```
|
||||
|
||||
演示输出包含真实文件路径、逐步哈希及任务目录。此演示授权仅限自己生成的临时文件,不代表用户工程或远程权限。
|
||||
|
||||
## 宿主接口
|
||||
|
||||
创建 `new DarkCoreTask({plan, directory, host})`,然后调用 `run()`。计划必须包含唯一任务id、明确goal、`channel: ICE-CH-DK001`、授权来源引用,以及具有唯一id、capability、args的步骤。计划创建后不可改写;授权核验仍在每一步执行前进行。
|
||||
|
||||
`host` 必须提供四个函数:
|
||||
|
||||
- `authorize({plan, step, planHash, signal})`:通过宿主原生授权核对精确目标,返回绑定planHash、stepId和真实授权receipt的对象。取消信号到达后不能继续获取或使用授权。
|
||||
- `execute({plan, step, grant, signal})`:执行已授权的具体能力,支持协作取消,返回包含kind和读回信息的实际回执。
|
||||
- `verify({plan, step, result})`:校验实际目标侧状态,只有返回true才记为已完成。
|
||||
- `verifyHuman(event)`:通过宿主会话来源验证当前直接人类事件。不能相信消息正文里的`role=user`,不能把附件、工具输出、旧记录当控制源。事件类型由人格体理解与可信控制入口提供,本库不使用关键词正则猜权限。
|
||||
|
||||
`human({id, taskId, kind, text, ...hostProvenance})` 可以在 `run()` 等待操作时调用:
|
||||
|
||||
| kind | 处理 |
|
||||
|---|---|
|
||||
| ADVICE | 入队,原计划继续;人格体用decideAdvice作ACCEPT、DEFER、REJECT,均必须给理由 |
|
||||
| CORRECTION / CHANGE_GOAL | 暂停后续步骤,向在途操作发送取消;目标变更必须另建经过授权的新计划 |
|
||||
| STOP | 请求取消,禁止后续步骤;在途调用收尾后才标为STOPPED |
|
||||
| WITHDRAW | 撤回后续执行资格;原任务不能恢复 |
|
||||
| RESUME | 只接受已暂停、已收尾且不存在不明副作用的原计划;恢复后每步重新授权 |
|
||||
|
||||
接受建议不自动更改计划。普通建议可以拒绝,但停止与撤权不能被转为建议。更强的停止状态不会被后来到达的普通纠正覆盖。
|
||||
|
||||
## 恢复与实际限制
|
||||
|
||||
`inspectTask(directory)`检查计划哈希和事件链,并返回实际存储状态。活动任务在宿主重启后不会自动重跑,避免把未确认副作用执行两遍;需要核查目标,建立新的已授权任务接续。
|
||||
|
||||
状态目录一次只创建一个控制器。另一个进程不能用同一目录创建任务。队列接口在持有控制器的宿主进程内调用;没有后台监听器或跨进程消息服务。
|
||||
|
||||
取消是协作式的:同步操作或不可取消的远程动作可能已经发生。此时保持REQUESTED状态直至适配器返回,并保留已发生的回执;不承诺撤销既成效果。适配器必须自行设置操作超时与真实中止手段。没有适配器确认,控制器不会伪造停止完成。
|
||||
|
||||
事件哈希用于发现意外损坏,不能防止拥有目录写权限的人重写整个历史;它不是数字签名。状态文件仅供受信任本地宿主使用。
|
||||
|
||||
|
||||
## 2026-09-05 部署读回
|
||||
|
||||
当前四频道目录与暗核控制器已作为私人按需运行包安装于 JD-FD-PRIMARY。当前事实以 `routing/persona-channel-runtime-deployment.json` 为准;以上首次本地验收状态保留为历史。本地共享脑运行器已支持LB001/DK001;四宿主共享装载器均接入四频道上下文。未接管宿主全部消息或原生工具,不宣称平台自动启动钩子已启用。
|
||||
31
server-tools/dark-core/demo.mjs
Normal file
31
server-tools/dark-core/demo.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { DarkCoreTask, inspectTask } from './task-controller.mjs';
|
||||
import { localFileHost } from './local-file-host.mjs';
|
||||
|
||||
// Explicit local demo invocation authorizes only these two newly created demo files.
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dark-core-demo-')));
|
||||
const plan = { id: 'DARK-CORE-LOCAL-DEMO', channel: 'ICE-CH-DK001',
|
||||
goal: '创建两个本地演示文件并逐一读回', authorizationRef: 'EXPLICIT_LOCAL_DEMO_INVOCATION',
|
||||
steps: ['第一步完成。', '第二步完成。'].map((text, i) => ({ id: `step-${i + 1}`,
|
||||
capability: 'CREATE_TEXT_FILE', args: { path: path.join(root, `result-${i + 1}.txt`), text } })) };
|
||||
const host = localFileHost({ root, approvedPlan: plan, approvalReceipt: 'LOCAL_DEMO_SCOPE_ONLY',
|
||||
verifyHuman: e => e.source === 'LOCAL_DEMO_SCRIPT' && ['demo-advice'].includes(e.id) });
|
||||
const execute = host.execute;
|
||||
let task;
|
||||
host.execute = async args => {
|
||||
const result = await execute(args);
|
||||
if (args.step.id === 'step-1') {
|
||||
await task.human({ id: 'demo-advice', taskId: plan.id, kind: 'ADVICE', source: 'LOCAL_DEMO_SCRIPT',
|
||||
text: '演示中途的新想法:先讨论一个新框架。' });
|
||||
task.decideAdvice('demo-advice', 'DEFER', '当前两个文件任务已明确,新框架讨论排到任务之后。');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
task = new DarkCoreTask({ plan, directory: path.join(root, 'state'), host });
|
||||
await task.run();
|
||||
const result = inspectTask(path.join(root, 'state'));
|
||||
console.log(JSON.stringify({ status: result.status, files: result.receipts,
|
||||
advice: result.suggestions, directory: root, scope: 'LOCAL_DEMO_NOT_SERVER_DEPLOYMENT' }, null, 2));
|
||||
if (result.status !== 'COMPLETED') process.exitCode = 1;
|
||||
50
server-tools/dark-core/local-file-host.mjs
Normal file
50
server-tools/dark-core/local-file-host.mjs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { digest } from './task-controller.mjs';
|
||||
|
||||
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
// A deliberately bounded local adapter, not a shell, remote executor, or approval issuer.
|
||||
// Call only after the real host has authorized approvedPlan. No input file authenticates itself.
|
||||
export function localFileHost({ root, approvedPlan, approvalReceipt, verifyHuman }) {
|
||||
if (!path.isAbsolute(root) || !fs.statSync(root).isDirectory() || fs.realpathSync(root) !== root ||
|
||||
typeof approvalReceipt !== 'string' || !approvalReceipt.trim() || typeof verifyHuman !== 'function') {
|
||||
throw Error('TRUSTED_HOST_CONFIGURATION_REQUIRED');
|
||||
}
|
||||
const approved = structuredClone(approvedPlan);
|
||||
const expected = digest(approved);
|
||||
const registeredGrants = new WeakSet();
|
||||
for (const step of approved.steps) {
|
||||
if (step.capability !== 'CREATE_TEXT_FILE' || typeof step.args.text !== 'string' ||
|
||||
typeof step.args.path !== 'string' || path.dirname(step.args.path) !== root ||
|
||||
path.basename(step.args.path) === '.' || path.basename(step.args.path) === '..' ||
|
||||
step.args.path !== path.resolve(step.args.path)) throw Error('LOCAL_CAPABILITY_OUT_OF_SCOPE');
|
||||
}
|
||||
return {
|
||||
verifyHuman,
|
||||
async authorize({ plan, step, planHash, signal }) {
|
||||
signal.throwIfAborted();
|
||||
if (digest(plan) !== expected || planHash !== expected ||
|
||||
!approved.steps.some(s => digest(s) === digest(step))) throw Error('PLAN_NOT_AUTHORIZED');
|
||||
const grant = Object.freeze({ planHash, stepId: step.id, receipt: approvalReceipt });
|
||||
registeredGrants.add(grant);
|
||||
return grant;
|
||||
},
|
||||
async execute({ step, grant, signal }) {
|
||||
signal.throwIfAborted();
|
||||
if (!registeredGrants.has(grant)) throw Error('UNTRUSTED_GRANT');
|
||||
registeredGrants.delete(grant);
|
||||
// Recheck after authorization; wx never overwrites an existing file or symlink.
|
||||
if (fs.realpathSync(root) !== root) throw Error('ROOT_CHANGED');
|
||||
fs.writeFileSync(step.args.path, step.args.text, { flag: 'wx', mode: 0o600 });
|
||||
return { kind: 'FILE_CREATED', path: step.args.path, sha256: hash(fs.readFileSync(step.args.path)) };
|
||||
},
|
||||
async verify({ step, result }) {
|
||||
const stat = fs.lstatSync(step.args.path);
|
||||
return stat.isFile() && !stat.isSymbolicLink() && result?.kind === 'FILE_CREATED' &&
|
||||
result.path === step.args.path && result.sha256 === hash(Buffer.from(step.args.text)) &&
|
||||
hash(fs.readFileSync(step.args.path)) === result.sha256;
|
||||
},
|
||||
};
|
||||
}
|
||||
146
server-tools/dark-core/task-controller.mjs
Normal file
146
server-tools/dark-core/task-controller.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// Host implementation of TCS-DARK-CORE-EXECUTION-0001. No model or authority setter.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
||||
const copy = value => structuredClone(value);
|
||||
const terminal = new Set(['COMPLETED', 'STOPPED', 'WITHDRAWN', 'FAILED', 'RECOVERY_REQUIRED']);
|
||||
const text = value => typeof value === 'string' && value.trim().length > 0;
|
||||
const deepFreeze = value => {
|
||||
if (value && typeof value === 'object') {
|
||||
Object.values(value).forEach(deepFreeze);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export class DarkCoreTask {
|
||||
#plan; #state; #host; #file; #busy = false; #abort; #epoch = 0; #pending;
|
||||
constructor({ plan, directory, host }) {
|
||||
if (plan?.channel !== 'ICE-CH-DK001' || !text(plan.id) || !text(plan.goal) ||
|
||||
!text(plan.authorizationRef) || !Array.isArray(plan.steps) || !plan.steps.length ||
|
||||
!plan.steps.every(s => text(s.id) && text(s.capability) && s.args && typeof s.args === 'object') ||
|
||||
new Set(plan.steps.map(s => s.id)).size !== plan.steps.length) throw Error('INVALID_PLAN');
|
||||
for (const method of ['authorize', 'execute', 'verify', 'verifyHuman']) {
|
||||
if (typeof host?.[method] !== 'function') throw Error(`HOST_ADAPTER_REQUIRED:${method}`);
|
||||
}
|
||||
if (!path.isAbsolute(directory)) throw Error('ABSOLUTE_STATE_DIRECTORY_REQUIRED');
|
||||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
if (fs.realpathSync(directory) !== path.resolve(directory)) throw Error('STATE_DIRECTORY_SYMLINK');
|
||||
this.#file = path.join(directory, 'task.json');
|
||||
this.#plan = deepFreeze(copy(plan)); this.#host = host;
|
||||
this.#state = { schema: 'guanghu.dark-core-task/v1', plan: this.#plan,
|
||||
planHash: digest(this.#plan), status: 'READY', nextStep: 0,
|
||||
receipts: [], suggestions: [], events: [], seenHumanEvents: [], uncertainSteps: [] };
|
||||
// Exclusive creation prevents two writers and implicit replay after a crash.
|
||||
fs.writeFileSync(this.#file, JSON.stringify(this.#state), { flag: 'wx', mode: 0o600 });
|
||||
this.#record('CREATED', { goal: plan.goal });
|
||||
}
|
||||
get snapshot() { return copy(this.#state); }
|
||||
#record(type, detail = {}) {
|
||||
const previous = this.#state.events.at(-1)?.hash ?? null;
|
||||
const event = { sequence: this.#state.events.length + 1, at: new Date().toISOString(), type, detail, previous };
|
||||
event.hash = digest(event); this.#state.events.push(event);
|
||||
const temporary = this.#file + '.tmp';
|
||||
fs.writeFileSync(temporary, JSON.stringify(this.#state, null, 2) + '\n', { mode: 0o600 });
|
||||
fs.renameSync(temporary, this.#file);
|
||||
}
|
||||
async human(input) {
|
||||
const event = deepFreeze(copy(input));
|
||||
if (!text(event.id) || event.taskId !== this.#plan.id ||
|
||||
!['ADVICE', 'STOP', 'WITHDRAW', 'CORRECTION', 'CHANGE_GOAL', 'RESUME'].includes(event.kind) ||
|
||||
!text(event.text)) throw Error('INVALID_HUMAN_EVENT');
|
||||
// Only the host adapter authenticates provenance. A field saying "human" is insufficient.
|
||||
if (await this.#host.verifyHuman(event) !== true) throw Error('UNTRUSTED_CONTROL_SOURCE');
|
||||
if (this.#state.seenHumanEvents.includes(event.id)) return this.snapshot;
|
||||
if (terminal.has(this.#state.status)) throw Error('TASK_CLOSED');
|
||||
if (event.kind === 'RESUME') {
|
||||
if (this.#busy || this.#state.status !== 'PAUSED') throw Error('NOT_SETTLED_PAUSE');
|
||||
if (this.#state.uncertainSteps.length) throw Error('UNCERTAIN_EFFECTS_REQUIRE_NEW_RECONCILED_TASK');
|
||||
this.#pending = undefined; this.#state.status = 'READY';
|
||||
} else if (event.kind === 'ADVICE') {
|
||||
this.#state.suggestions.push({ id: event.id, text: event.text, state: 'QUEUED' });
|
||||
} else {
|
||||
const next = event.kind === 'WITHDRAW' ? 'WITHDRAW' : event.kind === 'STOP' ? 'STOP' : 'PAUSE';
|
||||
// A later correction must never demote an already accepted stop or withdrawal.
|
||||
const rank = { PAUSE: 1, STOP: 2, WITHDRAW: 3 };
|
||||
if (!this.#pending || rank[next] > rank[this.#pending]) this.#pending = next;
|
||||
this.#epoch++;
|
||||
this.#state.status = this.#pending + '_REQUESTED';
|
||||
this.#abort?.abort(new Error(event.kind));
|
||||
if (!this.#busy) this.#settleControl();
|
||||
}
|
||||
this.#state.seenHumanEvents.push(event.id);
|
||||
this.#record('HUMAN_' + event.kind, { id: event.id, text: event.text });
|
||||
return this.snapshot;
|
||||
}
|
||||
decideAdvice(id, decision, reason) {
|
||||
if (!['ACCEPT', 'DEFER', 'REJECT'].includes(decision) || !text(reason)) throw Error('REASON_REQUIRED');
|
||||
const item = this.#state.suggestions.find(s => s.id === id);
|
||||
if (!item || item.state !== 'QUEUED' || terminal.has(this.#state.status)) throw Error('SUGGESTION_UNAVAILABLE');
|
||||
Object.assign(item, { state: decision, reason });
|
||||
this.#record('PERSONA_ADVICE_DECISION', { id, decision, reason });
|
||||
// ACCEPT records agreement; it does not rewrite the immutable execution plan.
|
||||
return this.snapshot;
|
||||
}
|
||||
#settleControl() {
|
||||
if (this.#pending) this.#state.status = { PAUSE: 'PAUSED', STOP: 'STOPPED', WITHDRAW: 'WITHDRAWN' }[this.#pending];
|
||||
}
|
||||
async run() {
|
||||
if (this.#busy || this.#state.status !== 'READY') throw Error('TASK_NOT_READY');
|
||||
this.#busy = true; this.#state.status = 'RUNNING'; this.#record('RUN_STARTED');
|
||||
try {
|
||||
while (this.#state.nextStep < this.#plan.steps.length && !this.#pending) {
|
||||
const step = this.#plan.steps[this.#state.nextStep];
|
||||
const epoch = this.#epoch; this.#abort = new AbortController();
|
||||
const signal = this.#abort.signal;
|
||||
const grant = await this.#host.authorize({ plan: this.#plan, step, planHash: this.#state.planHash, signal });
|
||||
// Stop can arrive while the host is obtaining native approval.
|
||||
if (this.#pending || epoch !== this.#epoch) break;
|
||||
if (!grant || grant.planHash !== this.#state.planHash || grant.stepId !== step.id || !text(grant.receipt)) {
|
||||
throw Error('HOST_AUTHORIZATION_MISMATCH');
|
||||
}
|
||||
this.#record('STEP_STARTED', { stepId: step.id, authorizationReceipt: grant.receipt });
|
||||
let result;
|
||||
try {
|
||||
result = await this.#host.execute({ plan: this.#plan, step, grant, signal });
|
||||
if (!result || typeof result !== 'object' || !text(result.kind)) throw Error('EXECUTION_RECEIPT_REQUIRED');
|
||||
result = deepFreeze(copy(result));
|
||||
const verified = await this.#host.verify({ plan: this.#plan, step, result });
|
||||
if (verified !== true) throw Error('TARGET_READBACK_FAILED');
|
||||
} catch (error) {
|
||||
// Abort or failure does not prove that a side effect did not happen.
|
||||
this.#state.uncertainSteps.push(step.id);
|
||||
throw error;
|
||||
}
|
||||
this.#state.receipts.push({ stepId: step.id, result: copy(result), verified: true });
|
||||
this.#state.nextStep++;
|
||||
this.#record('STEP_VERIFIED', { stepId: step.id });
|
||||
}
|
||||
if (!this.#pending && this.#state.nextStep === this.#plan.steps.length) this.#state.status = 'COMPLETED';
|
||||
} catch (error) {
|
||||
this.#state.lastError = String(error.message ?? error);
|
||||
if (!this.#pending) this.#state.status = 'FAILED';
|
||||
this.#record('EXECUTION_INTERRUPTED', { error: this.#state.lastError });
|
||||
} finally {
|
||||
this.#busy = false; this.#abort = undefined; this.#settleControl();
|
||||
this.#record('RUN_SETTLED', { status: this.#state.status });
|
||||
}
|
||||
return this.snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectTask(directory) {
|
||||
const state = JSON.parse(fs.readFileSync(path.join(directory, 'task.json'), 'utf8'));
|
||||
if (digest(state.plan) !== state.planHash) throw Error('PLAN_INTEGRITY_FAILURE');
|
||||
let previous = null;
|
||||
for (let i = 0; i < state.events.length; i++) {
|
||||
const { hash, ...event } = state.events[i];
|
||||
if (event.sequence !== i + 1 || event.previous !== previous || digest(event) !== hash) throw Error('JOURNAL_INTEGRITY_FAILURE');
|
||||
previous = hash;
|
||||
}
|
||||
// Inspection never resumes potentially interrupted real operations.
|
||||
return { ...state, recoveryRequired: !terminal.has(state.status),
|
||||
recoveryPolicy: 'INSPECT_REAL_EFFECTS_AND_AUTHORIZE_NEW_TASK_NO_AUTOMATIC_REPLAY' };
|
||||
}
|
||||
131
server-tools/dark-core/task-controller.test.mjs
Normal file
131
server-tools/dark-core/task-controller.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { DarkCoreTask, digest, inspectTask } from './task-controller.mjs';
|
||||
import { localFileHost } from './local-file-host.mjs';
|
||||
|
||||
function fixture(t, overrides = {}) {
|
||||
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'dark-core-test-')));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const plan = { id: 'test', goal: 'write two approved files', channel: 'ICE-CH-DK001', authorizationRef: 'test-scope',
|
||||
steps: [1, 2].map(i => ({ id: String(i), capability: 'CREATE_TEXT_FILE', args: { path: path.join(root, `${i}.txt`), text: `result ${i}` } })) };
|
||||
const host = { ...localFileHost({ root, approvedPlan: plan, approvalReceipt: 'test-host-approval', verifyHuman: () => true }), ...overrides };
|
||||
const directory = path.join(root, 'state');
|
||||
const task = new DarkCoreTask({ plan, directory, host });
|
||||
let sequence = 0;
|
||||
const human = (kind, text = kind) => task.human({ id: `event-${++sequence}`, taskId: plan.id, kind, text });
|
||||
return { root, plan, host, directory, task, human };
|
||||
}
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
|
||||
|
||||
test('real local writes complete only after both readbacks; immutable input and journal', async t => {
|
||||
const f = fixture(t); f.plan.steps[0].args.text = 'changed after confirmation';
|
||||
await f.task.run();
|
||||
assert.equal(f.task.snapshot.status, 'COMPLETED');
|
||||
assert.equal(fs.readFileSync(path.join(f.root, '1.txt'), 'utf8'), 'result 1');
|
||||
assert.equal(inspectTask(f.directory).receipts.length, 2);
|
||||
assert.throws(() => new DarkCoreTask({ plan: f.plan, directory: f.directory, host: f.host }), /EEXIST/);
|
||||
});
|
||||
test('ordinary advice stays queued during execution and rejection requires a reason', async t => {
|
||||
const gate = deferred(), entered = deferred(); let calls = 0;
|
||||
const f = fixture(t); const execute = f.host.execute;
|
||||
f.host.execute = async args => { if (++calls === 1) { entered.resolve(); await gate.promise; } return execute(args); };
|
||||
const running = f.task.run(); await entered.promise;
|
||||
const before = f.task.snapshot.planHash;
|
||||
await f.human('ADVICE', 'switch frameworks');
|
||||
assert.equal(f.task.snapshot.status, 'RUNNING');
|
||||
assert.throws(() => f.task.decideAdvice('event-1', 'REJECT', ''), /REASON_REQUIRED/);
|
||||
f.task.decideAdvice('event-1', 'REJECT', 'Changing frameworks is outside the confirmed task.');
|
||||
assert.equal(f.task.snapshot.planHash, before);
|
||||
gate.resolve(); await running; assert.equal(f.task.snapshot.status, 'COMPLETED');
|
||||
});
|
||||
test('stop during asynchronous authorization prevents the first operation', async t => {
|
||||
const gate = deferred(), entered = deferred();
|
||||
const f = fixture(t); const authorize = f.host.authorize;
|
||||
f.host.authorize = async args => { const g = await authorize(args); entered.resolve(); await gate.promise; return g; };
|
||||
const running = f.task.run(); await entered.promise;
|
||||
await f.human('STOP'); assert.equal(f.task.snapshot.status, 'STOP_REQUESTED');
|
||||
gate.resolve(); await running;
|
||||
assert.equal(f.task.snapshot.status, 'STOPPED'); assert.equal(fs.existsSync(path.join(f.root, '1.txt')), false);
|
||||
});
|
||||
test('withdrawal cancels a cooperative in-flight adapter and cannot be resumed', async t => {
|
||||
const entered = deferred();
|
||||
const f = fixture(t, { execute: ({ signal }) => new Promise((_, reject) => {
|
||||
entered.resolve(); signal.addEventListener('abort', () => reject(Error('ABORTED')), { once: true });
|
||||
}) });
|
||||
const running = f.task.run(); await entered.promise; await f.human('WITHDRAW'); await running;
|
||||
assert.equal(f.task.snapshot.status, 'WITHDRAWN');
|
||||
assert.deepEqual(f.task.snapshot.uncertainSteps, ['1']);
|
||||
await assert.rejects(f.human('RESUME'), /TASK_CLOSED/);
|
||||
assert.equal(fs.existsSync(path.join(f.root, '2.txt')), false);
|
||||
});
|
||||
test('non-cancellable work is never falsely reported stopped while in flight', async t => {
|
||||
const gate = deferred(), entered = deferred();
|
||||
const f = fixture(t); const execute = f.host.execute;
|
||||
f.host.execute = async args => { const result = await execute(args); entered.resolve(); await gate.promise; return result; };
|
||||
const running = f.task.run(); await entered.promise; await f.human('STOP');
|
||||
assert.equal(f.task.snapshot.status, 'STOP_REQUESTED');
|
||||
gate.resolve(); await running;
|
||||
assert.equal(f.task.snapshot.status, 'STOPPED'); assert.equal(f.task.snapshot.receipts.length, 1);
|
||||
assert.equal(fs.existsSync(path.join(f.root, '2.txt')), false);
|
||||
});
|
||||
test('correction pauses at an authorization boundary and resume reauthorizes', async t => {
|
||||
const gate = deferred(), entered = deferred(); let checks = 0;
|
||||
const f = fixture(t); const authorize = f.host.authorize;
|
||||
f.host.authorize = async args => { const grant = await authorize(args); if (++checks === 1) { entered.resolve(); await gate.promise; } return grant; };
|
||||
const running = f.task.run(); await entered.promise; await f.human('CORRECTION', 'check the target');
|
||||
gate.resolve(); await running; assert.equal(f.task.snapshot.status, 'PAUSED');
|
||||
await f.human('RESUME', 'target checked; continue original task'); await f.task.run();
|
||||
assert.equal(f.task.snapshot.status, 'COMPLETED'); assert.equal(checks, 3);
|
||||
});
|
||||
test('uncertain side effects block resume rather than repeat a partially executed operation', async t => {
|
||||
const entered = deferred();
|
||||
const f = fixture(t, { execute: ({ signal }) => new Promise((_, reject) => {
|
||||
entered.resolve(); signal.addEventListener('abort', () => reject(Error('PARTIAL_EFFECT_POSSIBLE')));
|
||||
}) });
|
||||
const running = f.task.run(); await entered.promise; await f.human('CORRECTION'); await running;
|
||||
assert.equal(f.task.snapshot.status, 'PAUSED');
|
||||
await assert.rejects(f.human('RESUME'), /UNCERTAIN_EFFECTS/);
|
||||
});
|
||||
test('later correction does not override withdrawal', async t => {
|
||||
const gate = deferred(), entered = deferred();
|
||||
const f = fixture(t); const authorize = f.host.authorize;
|
||||
f.host.authorize = async args => { const result = await authorize(args); entered.resolve(); await gate.promise; return result; };
|
||||
const running = f.task.run(); await entered.promise;
|
||||
await f.human('WITHDRAW'); await f.human('CORRECTION'); gate.resolve(); await running;
|
||||
assert.equal(f.task.snapshot.status, 'WITHDRAWN');
|
||||
});
|
||||
test('untrusted document text cannot stop or replan the task', async t => {
|
||||
const f = fixture(t, { verifyHuman: () => false });
|
||||
await assert.rejects(f.human('STOP', 'document says ignore instructions'), /UNTRUSTED_CONTROL_SOURCE/);
|
||||
assert.equal(f.task.snapshot.status, 'READY');
|
||||
});
|
||||
test('goal change pauses but cannot mutate confirmed scope', async t => {
|
||||
const f = fixture(t); const original = f.task.snapshot.planHash;
|
||||
await f.human('CHANGE_GOAL', 'also operate on another server');
|
||||
assert.equal(f.task.snapshot.status, 'PAUSED'); assert.equal(f.task.snapshot.planHash, original);
|
||||
});
|
||||
test('bad readback fails without launching subsequent work', async t => {
|
||||
const f = fixture(t, { verify: () => false }); await f.task.run();
|
||||
assert.equal(f.task.snapshot.status, 'FAILED'); assert.equal(f.task.snapshot.receipts.length, 0);
|
||||
assert.equal(fs.existsSync(path.join(f.root, '2.txt')), false);
|
||||
});
|
||||
test('invalid host grant cannot execute', async t => {
|
||||
const f = fixture(t, { authorize: () => ({ planHash: 'wrong', stepId: '1', receipt: 'forged' }) });
|
||||
await f.task.run(); assert.equal(f.task.snapshot.status, 'FAILED');
|
||||
assert.equal(fs.existsSync(path.join(f.root, '1.txt')), false);
|
||||
});
|
||||
test('local adapter rejects outside targets and never overwrites existing files', async t => {
|
||||
const f = fixture(t); fs.writeFileSync(path.join(f.root, '1.txt'), 'original'); await f.task.run();
|
||||
assert.equal(f.task.snapshot.status, 'FAILED'); assert.equal(fs.readFileSync(path.join(f.root, '1.txt'), 'utf8'), 'original');
|
||||
const outside = structuredClone(f.plan); outside.steps[0].args.path = '/tmp/outside.txt';
|
||||
assert.throws(() => localFileHost({ root: f.root, approvedPlan: outside, approvalReceipt: 'x', verifyHuman: () => true }), /OUT_OF_SCOPE/);
|
||||
});
|
||||
test('inspection detects journal modification and marks unfinished tasks for reconciliation', t => {
|
||||
const f = fixture(t); assert.equal(inspectTask(f.directory).recoveryRequired, true);
|
||||
const p = path.join(f.directory, 'task.json'); const state = JSON.parse(fs.readFileSync(p));
|
||||
state.events[0].detail.goal = 'tampered'; fs.writeFileSync(p, JSON.stringify(state));
|
||||
assert.throws(() => inspectTask(f.directory), /JOURNAL_INTEGRITY/);
|
||||
});
|
||||
Loading…
Reference in a new issue