372 lines
22 KiB
JavaScript
372 lines
22 KiB
JavaScript
import { DatabaseSync } from "node:sqlite";
|
||
import assert from "node:assert/strict";
|
||
import { test } from "node:test";
|
||
import { ChainStore } from "../src/chain-store.js";
|
||
import { Timeline } from "../src/timeline.js";
|
||
import { CoreProjection } from "../src/core-projection.js";
|
||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { tmpdir } from "node:os";
|
||
import { estimateTokens } from "../src/token-budget.js";
|
||
|
||
function fixture() {
|
||
const db = new DatabaseSync(":memory:"), timeline = new Timeline(db), chain = new ChainStore(db, timeline);
|
||
const append = (id, text = "用户讨论报销工作与发票整理。".repeat(100), kind = "user", at = 1) => timeline.append({ id, text, kind, at, source: { channel: "test", sender: "owner" } });
|
||
const claim = (mode = "compact") => {
|
||
if (mode === "organize") {
|
||
chain.request({ request_id: `detach:${Math.random()}`, mode: "compact", trigger: "threshold" }, 5);
|
||
const compact = chain.claim({}, 6).job;
|
||
chain.commit({ job_id: compact.id, token: compact.token, covered_node_ids: compact.nodes.map(n => n.id), summary: "已保存经历,等待归档。" }, 7);
|
||
}
|
||
chain.request({ request_id: `test:${Math.random()}`, mode, trigger: mode === "organize" ? "hourly" : "explicit" }, 10);
|
||
return chain.claim({}, 20).job;
|
||
};
|
||
const proposal = (job, extra = {}) => ({ job_id: job.id, token: job.token, covered_node_ids: job.nodes.map(n => n.id),
|
||
summary: "讨论报销,尚未提交。详细依据可以展开原始事件。", objects: [], edges: [], core: [], catalog: [],
|
||
dispositions: job.events.map(e => ({ event_id: e.id, outcome: [...(extra.objects ?? []), ...(extra.core ?? [])].some(o => o.source_refs.includes(e.id)) ? "linked" : "archive_only", reason: "已审阅", progress: "", target_ids: [] })),
|
||
reviewed_object_ids: job.review_objects.map(o => o.id), ...extra });
|
||
return { db, timeline, chain, append, claim, proposal };
|
||
}
|
||
const person = (id = "person:qhy", refs = ["meeting"]) => ({ id, type: "person", name: "钱灏宇", summary: "用户的同事,与报销工作有关。",
|
||
body: "已确认是同事。具体职责尚未确认。", aliases: ["灏宇"], cues: ["发票整理找谁沟通", "同事交接报销材料"], source_refs: refs, status: "active" });
|
||
|
||
test("atomic compaction preserves raw evidence and concurrent suffix; receipt retries are idempotent", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting"); const job = f.claim();
|
||
f.append("new", "正在整理时用户的新消息", "user", 30); f.chain.sync();
|
||
const receipt = f.chain.commit(f.proposal(job), 40);
|
||
assert.equal(f.chain.commit(f.proposal(job), 50).revision, receipt.revision);
|
||
const context = f.chain.context(); assert.equal(context.ready, true);
|
||
assert.ok(context.nodes.some(n => n.text.includes("新消息")));
|
||
assert.ok(context.nodes.some(n => n.id === receipt.node_id));
|
||
assert.ok(f.timeline.read({ id: "meeting" }).text.length > 1000);
|
||
assert.ok(f.chain.node({ id: receipt.node_id }).children.length);
|
||
f.chain.request({ request_id: "archive", mode: "organize" }, 51);
|
||
const archive = f.chain.claim({}, 52).job;
|
||
f.chain.commit(f.proposal(archive, { objects: [person()], catalog: [{ type: "person", summary: "有同事和报销协作人的相关记忆" }] }), 53);
|
||
assert.equal(f.chain.graph.find({ query: "钱灏宇" }).hits[0].id, "person:qhy");
|
||
assert.equal(f.chain.graph.find({ query: "灏宇" }).hits[0].id, "person:qhy");
|
||
assert.equal(f.chain.graph.find({ query: "发票整理找谁沟通" }).hits[0].id, "person:qhy");
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("invalid evidence rolls back graph, timeline and revision together", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting"); const job = f.claim("organize"), before = f.chain.context();
|
||
const revision = f.chain.revision();
|
||
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [person(), person("person:bad", ["not-in-batch"])] }), 40), /Evidence/);
|
||
assert.equal(f.chain.graph.object("person:qhy"), null);
|
||
assert.deepEqual(f.chain.context().nodes, before.nodes);
|
||
assert.equal(f.chain.revision(), revision);
|
||
assert.throws(() => f.chain.commit({ ...f.proposal(job), dispositions: [] }, 40), /every claimed event/);
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("core changes invalidate stale leases and expired leases can be reclaimed", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting"); const job = f.claim();
|
||
f.chain.bootstrapCore({ "SOUL.md": "做可靠的工作同事。" });
|
||
assert.throws(() => f.chain.commit(f.proposal(job), 40), /Memory changed/);
|
||
assert.equal(f.chain.fail({ job_id: job.id, token: job.token, error: "revision conflict" }, 50).released, true);
|
||
const next = f.chain.claim({}, 30000).job; assert.equal(next.id, job.id); assert.notEqual(next.token, job.token);
|
||
assert.throws(() => f.chain.commit(f.proposal(next), 300001), /Expired/);
|
||
assert.equal(f.chain.claim({}, 300002).job.id, job.id);
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("02:50 local nightly job waits five quiet minutes, is unique, and yields between batches", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting");
|
||
const now = Date.parse("2026-10-11T18:50:00Z");
|
||
f.chain.tick({ last_dialogue_at: now - 240000, busy: false }, now);
|
||
assert.equal(f.chain.claim({}, now).job, null);
|
||
f.chain.tick({ busy: true }, now + 120000);
|
||
assert.equal(f.chain.claim({}, now + 120000).job, null);
|
||
f.chain.tick({ busy: false }, now + 120001);
|
||
const job = f.chain.claim({}, now + 120001).job;
|
||
assert.equal(job.trigger, "nightly"); assert.equal(job.mode, "deep");
|
||
f.chain.commit(f.proposal(job), now + 120010);
|
||
f.chain.tick({ busy: false }, now + 240000);
|
||
assert.equal(f.chain.status().jobs.filter(j => j.trigger === "nightly").length, 1);
|
||
assert.equal(f.chain.claim({}, now + 240000).job, null);
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("threshold is not an every-message model trigger; explicit request is an event", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting", "普通聊天");
|
||
const now = Date.parse("2026-10-11T17:00:00Z");
|
||
f.chain.tick({ threshold_tokens: 8000 }, now);
|
||
assert.equal(f.chain.status().jobs.length, 0);
|
||
f.append("big", "财务材料".repeat(2000));
|
||
f.chain.tick({ threshold_tokens: 8000 }, now + 1);
|
||
assert.equal(f.chain.status().jobs[0].trigger, "threshold");
|
||
f.chain.request({ request_id: "explicit:one", scope: "整理钱灏宇有关的记忆", mode: "deep" }, now + 2);
|
||
f.chain.request({ request_id: "explicit:one", scope: "整理钱灏宇有关的记忆", mode: "deep" }, now + 3);
|
||
assert.equal(f.db.prepare("SELECT count(*) AS n FROM timeline WHERE kind='organize_request'").get().n, 1);
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("projects require user confirmation, graph links are cross-project and paginated", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting"); const job = f.claim("organize");
|
||
const project = { ...person("project:expense"), type: "project", name: "报销", summary: "每月报销与日常发票收集" };
|
||
f.chain.commit(f.proposal(job, { objects: [person(), project], edges: [{ from_id: "person:qhy", relation: "member_of", to_id: project.id, source_refs: ["meeting"] }] }), 40);
|
||
assert.equal(f.chain.graph.object(project.id).status, "candidate");
|
||
assert.equal(f.chain.graph.find({ project_id: project.id, type: "person" }).hits[0].id, "person:qhy");
|
||
assert.equal(f.chain.graph.read({ id: "person:qhy" }).edges.length, 1);
|
||
f.append("yes", "同意创建报销项目", "user", 60);
|
||
assert.throws(() => f.chain.projectDecision({ id: project.id, request_id: "decision", status: "active", source_id: "yes", user_quote: "不是用户原话" }), /exact user evidence/);
|
||
f.chain.projectDecision({ id: project.id, request_id: "decision", status: "active", source_id: "yes", user_quote: "同意创建报销项目" });
|
||
assert.equal(f.chain.graph.object(project.id).status, "active");
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("same name does not merge identities; read/cues never become new factual evidence", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting"); f.append("read", "钱灏宇的历史记忆", "memory_recall"); const job = f.claim("organize");
|
||
f.chain.inspect({job_id:job.id,token:job.token,kind:"evidence",id:"read"},30);
|
||
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [person("person:false", ["read"])] }), 40), /independent evidence/);
|
||
f.chain.commit(f.proposal(job, { objects: [person(), person("person:another")] }), 40);
|
||
assert.equal(f.chain.graph.find({ query: "钱灏宇" }).hits.length, 2);
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("catalog remains bounded, types can be fully paginated, no implicit graph fan-out", () => {
|
||
const f = fixture();
|
||
try {
|
||
for (let i = 0; i < 300; i++) f.chain.graph.apply({ objects: [person(`person:${i}`)] }, i + 1, new Map([["meeting", { kind: "user", text: "" }]]), i);
|
||
assert.ok(JSON.stringify(f.chain.graph.catalog()).length < 3000);
|
||
assert.equal(f.chain.graph.catalog().entries[0].count, 300);
|
||
assert.equal(f.chain.graph.find({ type: "person", offset: 290, limit: 20 }).hits.length, 10);
|
||
assert.throws(() => f.chain.graph.find({ limit: 300 }));
|
||
} finally { f.db.close(); }
|
||
});
|
||
test("credential metadata permits vault refs but rejects values; core identity needs user evidence", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting"); const job = f.claim("organize");
|
||
assert.throws(() => f.chain.commit(f.proposal(job, { core: [{ name: "USER.md", body: "用户喜欢自动付款", source_refs: ["meeting"] }] }), 40), /exact user evidence/);
|
||
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [{ ...person(), body: "sk-example-secret-01234567890" }] }), 40), /Credential/);
|
||
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [{ ...person("credential:bad"), type: "credential", secret_ref: "sk-example-secret-01234567890" }] }), 40), /Credential/);
|
||
f.chain.commit(f.proposal(job, { objects: [{ ...person("credential:model"), type: "credential", name: "模型服务", body: "用于模型调用;真实值仅存密钥库", secret_ref: "provider/example/api_key" }] }), 40);
|
||
assert.equal(f.chain.graph.object("credential:model").secret_ref, "provider/example/api_key");
|
||
} finally { f.db.close(); }
|
||
});
|
||
|
||
test("large events span bounded batches without skipping sibling chunks", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("big", "原始会议内容".repeat(5000));
|
||
const request = f.chain.request({ request_id: "large", mode: "compact" }, 10);
|
||
let batches = 0;
|
||
for (let now = 20; now < 100; now++) {
|
||
const job = f.chain.claim({ max_bytes: 24000 }, now).job;
|
||
if (!job) break;
|
||
assert.ok(Buffer.byteLength(JSON.stringify(job)) <= 24000);
|
||
f.chain.commit(f.proposal(job), now); batches++;
|
||
}
|
||
assert.ok(batches > 1);
|
||
assert.equal(f.chain.status({ id: request.id }).jobs[0].status, "done");
|
||
assert.equal(f.db.prepare("SELECT count(*) AS n FROM memory_nodes WHERE active=1 AND event_id='big'").get().n, 0);
|
||
assert.equal(f.timeline.read({ id: "big", limit: 32000 }).text.length, 30000);
|
||
} finally { f.db.close(); }
|
||
});
|
||
|
||
test("core files import human edits, project committed versions, and reject oversized/secret edits without data loss", () => {
|
||
const f = fixture(), dir = mkdtempSync(join(tmpdir(), "memory-core-"));
|
||
try {
|
||
writeFileSync(join(dir, "USER.md"), "用户喜欢简洁沟通。");
|
||
const projection = new CoreProjection(f.chain, dir); projection.refresh();
|
||
assert.equal(f.chain.graph.core()[0].body, "用户喜欢简洁沟通。");
|
||
const revision = f.chain.revision();
|
||
f.chain.editCore({ name: "USER.md", body: "用户喜欢简洁且有依据的回复。", expected_revision: revision });
|
||
projection.refresh();
|
||
assert.equal(readFileSync(join(dir, "USER.md"), "utf8"), "用户喜欢简洁且有依据的回复。");
|
||
writeFileSync(join(dir, "USER.md"), "用户自己修改的新偏好。"); projection.refresh();
|
||
assert.equal(f.chain.graph.core()[0].body, "用户自己修改的新偏好。");
|
||
writeFileSync(join(dir, "USER.md"), "password=never-store-this-value"); projection.refresh();
|
||
assert.equal(f.chain.graph.core()[0].body, "用户自己修改的新偏好。");
|
||
assert.ok(f.chain.meta("core_projection_error"));
|
||
assert.equal(readFileSync(join(dir, "USER.md"), "utf8"), "password=never-store-this-value");
|
||
assert.throws(() => f.chain.editCore({ name: "USER.md", body: "stale", expected_revision: revision }), /revision conflict/);
|
||
} finally { f.db.close(); rmSync(dir, { recursive: true, force: true }); }
|
||
});
|
||
|
||
test("organization survives SQLite reopen and a stale worker cannot overwrite a reclaimed batch", () => {
|
||
const dir = mkdtempSync(join(tmpdir(), "memory-restart-")), path = join(dir, "memory.sqlite");
|
||
let db = new DatabaseSync(path);
|
||
try {
|
||
let timeline = new Timeline(db), chain = new ChainStore(db, timeline);
|
||
timeline.append({ id: "event", kind: "user", at: 1, text: "旧事件需要保留".repeat(100), source: {} });
|
||
chain.request({ request_id: "restart" }, 10); const first = chain.claim({}, 20).job;
|
||
db.close(); db = new DatabaseSync(path); timeline = new Timeline(db); chain = new ChainStore(db, timeline);
|
||
assert.equal(chain.claim({}, 30).job, null);
|
||
const next = chain.claim({}, 180021).job;
|
||
assert.equal(next.id, first.id); assert.notEqual(next.token, first.token);
|
||
const proposal = { job_id: first.id, token: first.token, summary: "保留经历", covered_node_ids: first.nodes.map(n => n.id) };
|
||
assert.throws(() => chain.commit(proposal, 180022), /stale/);
|
||
chain.commit({ ...proposal, token: next.token, covered_node_ids: next.nodes.map(n => n.id) }, 180022);
|
||
assert.equal(db.prepare("PRAGMA integrity_check").get().integrity_check, "ok");
|
||
assert.equal(timeline.read({ id: "event" }).text, "旧事件需要保留".repeat(100));
|
||
} finally { db.close(); rmSync(dir, { recursive: true, force: true }); }
|
||
});
|
||
|
||
test("deep inspection grants evidence without letting uninspected objects be replaced", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("meeting"); let job = f.claim("organize");
|
||
f.chain.commit(f.proposal(job, { objects: [person()] }), 40);
|
||
f.append("new", "用户进一步说明了同事职责".repeat(100));
|
||
f.chain.request({ request_id: "detach-new", mode: "compact", trigger: "threshold" }, 50);
|
||
const detached=f.chain.claim({},51).job;
|
||
f.chain.commit(f.proposal(detached),52);
|
||
f.chain.request({ request_id: "inspect", mode: "organize", scope: "完全不相关词 abcxyz" }, 53);
|
||
job = f.chain.claim({}, 60).job;
|
||
const change = { ...person(), body: "更正后的同事信息", source_refs: ["new"] };
|
||
assert.throws(() => f.chain.commit(f.proposal(job, { objects: [change] }), 70), /Inspect existing/);
|
||
const inspected = f.chain.inspect({ job_id: job.id, token: job.token, kind: "object", id: "person:qhy" }, 70);
|
||
assert.equal(inspected.name, "钱灏宇");
|
||
f.chain.commit(f.proposal(job, { objects: [change] }), 80);
|
||
assert.equal(f.chain.graph.object("person:qhy").body, "更正后的同事信息");
|
||
assert.equal(f.chain.graph.read({ id: "person:qhy" }).total_sources, 2);
|
||
} finally { f.db.close(); }
|
||
});
|
||
|
||
test("timeline clipping returns newest nodes, explicit omission and separate byte/token accounting without deleting evidence", () => {
|
||
const f = fixture();
|
||
try {
|
||
for (let i=0;i<10;i++) f.append(`raw:${i}`, `事件${i}:${"中文正文".repeat(200)}`, "user", i);
|
||
const full=f.chain.context({max_tokens:100000,max_bytes:350000});
|
||
const view=f.chain.context({max_tokens:4000,max_bytes:350000});
|
||
assert.equal(view.ready,true); assert.equal(view.truncated,true);
|
||
assert.equal(view.nodes.at(-1).event_id,"raw:9");
|
||
assert.ok(view.view_estimated_tokens<=4000);
|
||
assert.equal(view.view_estimated_tokens,2+view.nodes.reduce((n,node)=>n+estimateTokens(node)+1,0));
|
||
assert.equal(view.omitted_nodes,full.nodes.length-view.nodes.length);
|
||
assert.deepEqual(f.chain.context({max_tokens:100000,max_bytes:350000}).nodes,full.nodes);
|
||
assert.ok(f.timeline.read({id:"raw:0"}).text.includes("中文正文"));
|
||
} finally {f.db.close();}
|
||
});
|
||
|
||
test("deep review covers dormant and archived objects across durable batches and never invents timeline events", () => {
|
||
const f=fixture();
|
||
try {
|
||
f.append("meeting");
|
||
for(let i=0;i<13;i++) f.chain.graph.apply({objects:[{...person(`person:old-${i}`),status:i===0?"archived":"active"}]},i+1,new Map([["meeting",{kind:"user",text:"证据"}]]),i);
|
||
let after, ids=[];
|
||
do { const page=f.chain.graph.browse({after,limit:3}); ids.push(...page.entries.map(x=>x.id)); after=page.next_after; } while(after);
|
||
assert.equal(ids.length,14); assert.ok(ids.includes("person:old-0"));
|
||
const req=f.chain.request({request_id:"global-review",mode:"deep",scope:"与旧人物无关的新工作"},10);
|
||
const reviewed=new Set(); let emptyBatches=0;
|
||
for(let now=20;now<40;now++) {
|
||
const job=f.chain.claim({},now).job; if(!job)break;
|
||
for(const obj of job.review_objects) reviewed.add(obj.id);
|
||
if(!job.nodes.length)emptyBatches++;
|
||
const before=f.chain.context().nodes;
|
||
f.chain.commit(f.proposal(job),now);
|
||
if(!job.nodes.length)assert.deepEqual(f.chain.context().nodes,before);
|
||
}
|
||
assert.equal(reviewed.size,14); assert.ok(emptyBatches>0);
|
||
const status=f.chain.status({id:req.id}).jobs[0];
|
||
assert.equal(status.status,"done"); assert.deepEqual({...status.object_review},{total:14,reviewed:14});
|
||
assert.equal(f.chain.graph.object("person:old-0").status,"archived");
|
||
}finally{f.db.close();}
|
||
});
|
||
|
||
test("failed deep batch does not advance review coverage",()=>{
|
||
const f=fixture();
|
||
try {
|
||
f.append("meeting"); const first=f.claim("deep");
|
||
f.chain.fail({job_id:first.id,token:first.token,error:"test failure"},30);
|
||
assert.equal(f.chain.status({id:first.id}).jobs[0].object_review.reviewed,0);
|
||
const retry=f.chain.claim({},30000).job;
|
||
assert.deepEqual(retry.review_objects.map(x=>x.id),first.review_objects.map(x=>x.id));
|
||
}finally{f.db.close();}
|
||
});
|
||
|
||
test("opaque Chinese evidence IDs remain expandable and credential metadata is not a secret", () => {
|
||
const f = fixture();
|
||
try {
|
||
f.append("会议记录/十月十二日", "讨论密钥管理:用途是模型调用,引用 provider/example/api_key,未提供真实值。".repeat(10));
|
||
const job = f.claim("organize");
|
||
assert.ok(f.chain.node({ id: "会议记录/十月十二日" }).text.includes("provider/example/api_key"));
|
||
f.chain.commit(f.proposal(job, { objects: [person("person:qhy", ["会议记录/十月十二日"])] }), 40);
|
||
assert.deepEqual(f.chain.graph.read({ id: "person:qhy" }).source_refs, ["会议记录/十月十二日"]);
|
||
f.append("quoted-secret", '{"api_key":"sk-example-secret-01234567890"}');
|
||
assert.equal(f.chain.node({ id: "quoted-secret" }).text, "[敏感内容未写入记忆]");
|
||
} finally { f.db.close(); }
|
||
});
|
||
|
||
test("failed model batches shrink on retry without consuming or dropping their original nodes", () => {
|
||
const f = fixture();
|
||
try {
|
||
for(let i=0;i<12;i++) f.append(`event:${i}`, "有效工作事实".repeat(20));
|
||
const first=f.claim(), before=f.chain.context().nodes;
|
||
assert.equal(first.nodes.length,8);
|
||
f.chain.fail({job_id:first.id,token:first.token,error:"Memory output truncated"},30);
|
||
const next=f.chain.claim({},30000).job;
|
||
assert.equal(next.id,first.id); assert.equal(next.nodes.length,4);
|
||
assert.deepEqual(f.chain.context().nodes,before);
|
||
f.chain.commit(f.proposal(next),30001);
|
||
assert.ok(f.chain.context().nodes.some(node=>node.event_id==="event:11"));
|
||
} finally { f.db.close(); }
|
||
});
|
||
|
||
test("classified output retries grow output first, preserve feedback and review coverage, then shrink at capacity", () => {
|
||
const f = fixture();
|
||
try {
|
||
for (let i=0;i<12;i++) f.append(`raw:${i}`, "需要长期保留的工作事实".repeat(20));
|
||
const first=f.claim("compact"), before=f.chain.context().nodes;
|
||
f.chain.fail({job_id:first.id,token:first.token,kind:"output",error:"output truncated",shrink_batch:false},30);
|
||
const retry=f.chain.claim({lease_ms:360000},30000).job;
|
||
assert.deepEqual(retry.nodes.map(n=>n.id),first.nodes.map(n=>n.id));
|
||
assert.equal(retry.output_failures,1); assert.equal(retry.retry_feedback.error,"output truncated");
|
||
assert.equal(f.db.prepare("SELECT lease_until FROM memory_organize_jobs WHERE id=?").get(first.id).lease_until,390000);
|
||
f.chain.fail({job_id:retry.id,token:retry.token,kind:"output",error:"capacity reached",shrink_batch:true},30001);
|
||
const smaller=f.chain.claim({},60000).job;
|
||
assert.equal(smaller.nodes.length,4); assert.equal(smaller.output_failures,2);
|
||
assert.deepEqual(f.chain.context().nodes,before);
|
||
f.chain.commit(f.proposal(smaller),60001);
|
||
assert.equal(f.chain.status({id:first.id}).jobs[0].retry,null);
|
||
assert.equal(f.chain.status({id:first.id}).jobs[0].failures.length,2);
|
||
assert.equal(f.chain.claim({},60002).job.output_failures,0);
|
||
} finally {f.db.close();}
|
||
});
|
||
|
||
test("nightly failures are durable, acknowledged once and never silently replayed by idle ticks", () => {
|
||
const f=fixture();
|
||
try {
|
||
f.append("meeting");
|
||
const now=Date.parse("2026-10-12T18:50:00Z");
|
||
f.chain.tick({busy:false,last_dialogue_at:now-300001},now);
|
||
let job;
|
||
for(let attempt=0;attempt<5;attempt++) {
|
||
job=f.chain.claim({},now+attempt*400000).job;
|
||
assert.ok(job);
|
||
f.chain.fail({job_id:job.id,token:job.token,kind:"schema",error:"objects.0.body: maxLength (limit=16000)",shrink_batch:false},now+attempt*400000+1);
|
||
}
|
||
assert.equal(f.chain.notices().jobs.length,1);
|
||
const rebuilt=new ChainStore(f.db,f.timeline);
|
||
assert.equal(rebuilt.notices().jobs.length,1);
|
||
rebuilt.tick({busy:false},now+2100000);
|
||
assert.equal(rebuilt.claim({},now+2100001).job,null);
|
||
assert.equal(rebuilt.status({id:job.id}).jobs[0].status,"failed");
|
||
assert.equal(rebuilt.status({id:job.id}).jobs[0].failures.length,5);
|
||
rebuilt.notices({ack:job.id});
|
||
assert.equal(new ChainStore(f.db,f.timeline).notices().jobs.length,0);
|
||
assert.ok(f.timeline.read({id:"meeting"}).text);
|
||
} finally {f.db.close();}
|
||
});
|
||
|
||
test("larger object bodies are accepted without weakening evidence or core limits", () => {
|
||
const f=fixture();
|
||
try {
|
||
f.append("meeting"); const job=f.claim("organize");
|
||
const body="工作知识".repeat(3000);
|
||
f.chain.commit(f.proposal(job,{objects:[{...person(),body}]}),40);
|
||
assert.equal(f.chain.graph.read({id:"person:qhy"}).body.length,12000);
|
||
assert.throws(()=>f.chain.graph.saveCore("USER.md","字".repeat(2401),[],2),/length/);
|
||
} finally {f.db.close();}
|
||
});
|