@sema-agent/core 2.2.0 → 2.4.0
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/dist/agents/send-message-tool.d.ts +4 -0
- package/dist/agents/send-message-tool.js +37 -24
- package/dist/agents/subagent.js +275 -127
- package/dist/agents/teacher.js +51 -23
- package/dist/brain/errors.d.ts +1 -0
- package/dist/brain/errors.js +14 -0
- package/dist/brain/stream-engine.js +3 -3
- package/dist/core/context-edit.js +2 -1
- package/dist/core/runner/assemble-result.d.ts +1 -0
- package/dist/core/runner/assemble-result.js +12 -8
- package/dist/core/runner/prepare-task.js +32 -7
- package/dist/core/runner/runtask.js +18 -5
- package/dist/core/runner/tool-output-projection.js +2 -1
- package/dist/core/store-contracts/checkpoint-store-contract.d.ts +37 -0
- package/dist/core/store-contracts/checkpoint-store-contract.js +195 -0
- package/dist/core/store-contracts/contract-harness.d.ts +6 -0
- package/dist/core/store-contracts/contract-harness.js +16 -0
- package/dist/core/store-contracts/contract-kit-version.d.ts +1 -0
- package/dist/core/store-contracts/contract-kit-version.js +2 -0
- package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -0
- package/dist/core/store-contracts/file-snapshot-store-contract.js +126 -0
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +6 -0
- package/dist/core/store-contracts/mailbox-store-contract.js +193 -0
- package/dist/core/store-contracts/session-repo-contract.d.ts +3 -0
- package/dist/core/store-contracts/session-repo-contract.js +36 -0
- package/dist/core/store-contracts/tool-result-store-contract.d.ts +3 -0
- package/dist/core/store-contracts/tool-result-store-contract.js +35 -0
- package/dist/core/task-notification.d.ts +2 -0
- package/dist/core/task-registry-agent.d.ts +7 -1
- package/dist/core/task-registry-agent.js +31 -2
- package/dist/core/task-registry-monitor.js +71 -6
- package/dist/core/task-registry-shared.d.ts +18 -1
- package/dist/core/task-registry-shared.js +5 -2
- package/dist/core/task-registry.d.ts +9 -0
- package/dist/core/task-registry.js +52 -7
- package/dist/core/tool-result-store.d.ts +3 -2
- package/dist/core/tool-result-store.js +12 -4
- package/dist/core/tools.d.ts +2 -0
- package/dist/core/tools.js +9 -0
- package/dist/core/trace.d.ts +7 -0
- package/dist/core/workflow-journal-store.d.ts +16 -0
- package/dist/core/workflow-journal-store.js +28 -0
- package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
- package/dist/engine/lsp/node-lsp-manager.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/orchestration/builtin-workflows.d.ts +1 -1
- package/dist/orchestration/builtin-workflows.js +11 -2
- package/dist/orchestration/workflow-governance.d.ts +6 -1
- package/dist/orchestration/workflow-governance.js +24 -4
- package/dist/orchestration/workflow-primitives.js +7 -1
- package/dist/orchestration/workflow.d.ts +16 -0
- package/dist/orchestration/workflow.js +94 -21
- package/dist/tools/fs/fs-bash.d.ts +7 -1
- package/dist/tools/fs/fs-bash.js +51 -20
- package/dist/tools/fs/fs-read.js +22 -11
- package/dist/tools/fs/fs-search-tools.js +3 -3
- package/dist/tools/fs/fs-shared.d.ts +20 -7
- package/dist/tools/fs/fs-shared.js +17 -3
- package/dist/tools/fs/fs-write.js +4 -4
- package/dist/tools/fs/index.d.ts +2 -0
- package/dist/tools/fs/index.js +7 -1
- package/dist/tools/fs/repo-map.js +2 -2
- package/dist/tools/fs/safety.d.ts +10 -0
- package/dist/tools/fs/safety.js +15 -1
- package/dist/tools/monitor.d.ts +2 -0
- package/dist/tools/monitor.js +20 -4
- package/dist/tools/web.js +6 -2
- package/dist/tools/worktree.js +46 -25
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function defaultSequentialRunner(_name, fn) {
|
|
2
|
+
return fn();
|
|
3
|
+
}
|
|
4
|
+
export function beginContract(runAssertion = defaultSequentialRunner) {
|
|
5
|
+
const pending = [];
|
|
6
|
+
return {
|
|
7
|
+
run: (name, fn) => {
|
|
8
|
+
const result = runAssertion(name, fn);
|
|
9
|
+
if (result instanceof Promise)
|
|
10
|
+
pending.push(result);
|
|
11
|
+
},
|
|
12
|
+
settle: async () => {
|
|
13
|
+
await Promise.all(pending);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const CONTRACT_KIT_ENGINE_VERSION: string;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { FileSnapshotStore } from "../file-snapshot-store.js";
|
|
2
|
+
import { type ContractAssertionRunner } from "./contract-harness.js";
|
|
3
|
+
export declare function fileSnapshotStoreContract(make: () => FileSnapshotStore, runAssertion?: ContractAssertionRunner): Promise<void>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { beginContract } from "./contract-harness.js";
|
|
4
|
+
const bytes = (s) => new TextEncoder().encode(s);
|
|
5
|
+
const sha256 = (s) => createHash("sha256").update(bytes(s)).digest("hex");
|
|
6
|
+
const srcBlob = async (hash) => hash === sha256("alpha") ? bytes("alpha") : hash === sha256("beta") ? bytes("beta") : undefined;
|
|
7
|
+
export async function fileSnapshotStoreContract(make, runAssertion) {
|
|
8
|
+
const { run, settle } = beginContract(runAssertion);
|
|
9
|
+
run("kit prerequisites: exportManifest/getBlob/putBlob/importManifest are implemented (REQUIRED by this kit)", async () => {
|
|
10
|
+
const probe = make();
|
|
11
|
+
const missing = ["exportManifest", "getBlob", "putBlob", "importManifest"].filter((m) => typeof probe[m] !== "function");
|
|
12
|
+
assert.equal(missing.length, 0, `backend does not implement ${missing.join(", ")} — required for the FileSnapshotStore contract kit`);
|
|
13
|
+
});
|
|
14
|
+
run("absent everything: has=false, listKeys=[], exportManifest=null, getBlob=undefined", async () => {
|
|
15
|
+
const store = make();
|
|
16
|
+
assert.equal(await store.has("sc", "k1"), false);
|
|
17
|
+
assert.deepEqual(await store.listKeys("sc"), []);
|
|
18
|
+
assert.equal(await store.exportManifest("sc", "k1"), null);
|
|
19
|
+
assert.equal(await store.getBlob(sha256("alpha")), undefined);
|
|
20
|
+
});
|
|
21
|
+
run("putBlob verifies content-address integrity: mismatched bytes → read_failed, nothing stored", async () => {
|
|
22
|
+
const store = make();
|
|
23
|
+
const r = await store.putBlob(sha256("beta"), bytes("alpha"));
|
|
24
|
+
assert.equal(r.ok, false);
|
|
25
|
+
if (!r.ok)
|
|
26
|
+
assert.equal(r.error.code, "read_failed");
|
|
27
|
+
assert.equal(await store.getBlob(sha256("beta")), undefined);
|
|
28
|
+
});
|
|
29
|
+
run("putBlob ok; a repeat putBlob for the same hash is an idempotent no-op; getBlob round-trips the bytes", async () => {
|
|
30
|
+
const store = make();
|
|
31
|
+
assert.deepEqual(await store.putBlob(sha256("alpha"), bytes("alpha")), { ok: true });
|
|
32
|
+
assert.deepEqual(await store.putBlob(sha256("alpha"), bytes("alpha")), { ok: true });
|
|
33
|
+
assert.deepEqual([...(await store.getBlob(sha256("alpha")))], [...bytes("alpha")]);
|
|
34
|
+
});
|
|
35
|
+
run("importManifest commits only after every blob verifies: has=true, exportManifest round-trips", async () => {
|
|
36
|
+
const store = make();
|
|
37
|
+
const manifest = new Map([["a.txt", sha256("alpha")], ["b.txt", sha256("beta")]]);
|
|
38
|
+
assert.deepEqual(await store.importManifest("sc", "k1", manifest, srcBlob), { ok: true });
|
|
39
|
+
assert.equal(await store.has("sc", "k1"), true);
|
|
40
|
+
const exported = await store.exportManifest("sc", "k1");
|
|
41
|
+
assert.deepEqual([...exported.entries()].sort(), [...manifest.entries()].sort());
|
|
42
|
+
});
|
|
43
|
+
run("importManifest is create-once: a second import for an existing key is a no-op (ok, zero fetches, manifest unchanged)", async () => {
|
|
44
|
+
const store = make();
|
|
45
|
+
const manifest = new Map([["a.txt", sha256("alpha")]]);
|
|
46
|
+
assert.deepEqual(await store.importManifest("sc", "k1", manifest, srcBlob), { ok: true });
|
|
47
|
+
const refetched = [];
|
|
48
|
+
const second = await store.importManifest("sc", "k1", new Map([["z.txt", sha256("beta")]]), async (h) => {
|
|
49
|
+
refetched.push(h);
|
|
50
|
+
return srcBlob(h);
|
|
51
|
+
});
|
|
52
|
+
assert.deepEqual(second, { ok: true });
|
|
53
|
+
assert.deepEqual(refetched, []);
|
|
54
|
+
assert.deepEqual([...(await store.exportManifest("sc", "k1")).entries()], [...manifest.entries()]);
|
|
55
|
+
});
|
|
56
|
+
run("importManifest is FAIL-CLOSED: a missing or hash-mismatched source blob → read_failed, no manifest committed", async () => {
|
|
57
|
+
const store = make();
|
|
58
|
+
const manifest = new Map([["c.txt", "0".repeat(64)]]);
|
|
59
|
+
const miss = await store.importManifest("sc", "k2", manifest, async () => undefined);
|
|
60
|
+
assert.equal(miss.ok, false);
|
|
61
|
+
if (!miss.ok)
|
|
62
|
+
assert.equal(miss.error.code, "read_failed");
|
|
63
|
+
assert.equal(await store.has("sc", "k2"), false);
|
|
64
|
+
const mism = await store.importManifest("sc", "k2", manifest, async () => bytes("wrong-bytes"));
|
|
65
|
+
assert.equal(mism.ok, false);
|
|
66
|
+
if (!mism.ok)
|
|
67
|
+
assert.equal(mism.error.code, "read_failed");
|
|
68
|
+
assert.equal(await store.has("sc", "k2"), false);
|
|
69
|
+
});
|
|
70
|
+
run("RB-361 a throwing source fetch → read_failed NAMING the blob hash, byte-identical wording (since 2.2.0)", async () => {
|
|
71
|
+
const store = make();
|
|
72
|
+
const hash = sha256("x");
|
|
73
|
+
const r = await store.importManifest("sc", "k3", new Map([["d.txt", hash]]), () => Promise.reject(new Error("transport down")));
|
|
74
|
+
assert.equal(r.ok, false);
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
assert.equal(r.error.code, "read_failed");
|
|
77
|
+
assert.equal(r.error.message, `source blob ${hash} fetch failed: transport down`);
|
|
78
|
+
}
|
|
79
|
+
assert.equal(await store.has("sc", "k3"), false);
|
|
80
|
+
});
|
|
81
|
+
run("RB-361 a path-escaping hash is refused as UNSAFE before any fetch, byte-identical wording (since 2.2.0)", async () => {
|
|
82
|
+
const store = make();
|
|
83
|
+
const fetched = [];
|
|
84
|
+
const r = await store.importManifest("sc", "k4", new Map([["e.txt", "../../escape"]]), async (h) => {
|
|
85
|
+
fetched.push(h);
|
|
86
|
+
return undefined;
|
|
87
|
+
});
|
|
88
|
+
assert.equal(r.ok, false);
|
|
89
|
+
if (!r.ok) {
|
|
90
|
+
assert.equal(r.error.code, "read_failed");
|
|
91
|
+
assert.equal(r.error.message, `unsafe blob hash "../../escape"`);
|
|
92
|
+
}
|
|
93
|
+
assert.deepEqual(fetched, []);
|
|
94
|
+
assert.equal(await store.has("sc", "k4"), false);
|
|
95
|
+
});
|
|
96
|
+
run("restore of a missing key → not_found (never a throw out of the seam)", async () => {
|
|
97
|
+
const store = make();
|
|
98
|
+
const stubEnv = {};
|
|
99
|
+
const r = await store.restore("sc", "nope", stubEnv, "/nonexistent-root");
|
|
100
|
+
assert.equal(r.ok, false);
|
|
101
|
+
if (!r.ok)
|
|
102
|
+
assert.equal(r.error.code, "not_found");
|
|
103
|
+
});
|
|
104
|
+
run("listKeys reflects only committed keys and is scope-isolated", async () => {
|
|
105
|
+
const store = make();
|
|
106
|
+
await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
|
|
107
|
+
await store.importManifest("sc", "k2", new Map([["c.txt", "0".repeat(64)]]), async () => undefined);
|
|
108
|
+
assert.deepEqual(((await store.listKeys("sc")) ?? []).slice().sort(), ["k1"]);
|
|
109
|
+
assert.deepEqual(await store.listKeys("other"), []);
|
|
110
|
+
});
|
|
111
|
+
run("reap keeps listed keys and their blobs", async () => {
|
|
112
|
+
const store = make();
|
|
113
|
+
await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
|
|
114
|
+
assert.equal(await store.reap("sc", ["k1"]), 0);
|
|
115
|
+
assert.deepEqual(await store.listKeys("sc"), ["k1"]);
|
|
116
|
+
assert.deepEqual([...(await store.getBlob(sha256("alpha")))], [...bytes("alpha")]);
|
|
117
|
+
});
|
|
118
|
+
run("reap keep-nothing drops the key and GCs the now-unreferenced blobs", async () => {
|
|
119
|
+
const store = make();
|
|
120
|
+
await store.importManifest("sc", "k1", new Map([["a.txt", sha256("alpha")]]), srcBlob);
|
|
121
|
+
assert.equal(await store.reap("sc", []), 1);
|
|
122
|
+
assert.equal(await store.has("sc", "k1"), false);
|
|
123
|
+
assert.equal(await store.getBlob(sha256("alpha")), undefined);
|
|
124
|
+
});
|
|
125
|
+
await settle();
|
|
126
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MailboxStore } from "../mailbox-store.js";
|
|
2
|
+
import { type ContractAssertionRunner } from "./contract-harness.js";
|
|
3
|
+
export declare const MAILBOX_CONTRACT_SCOPE = "default";
|
|
4
|
+
export declare function mailboxStoreContract(mk: () => MailboxStore, runAssertion?: ContractAssertionRunner): Promise<void>;
|
|
5
|
+
export declare function mailboxAckOwnershipContract(mk: () => MailboxStore, runAssertion?: ContractAssertionRunner): Promise<void>;
|
|
6
|
+
export declare function mailboxBundledOnlyContract(mk: () => MailboxStore, runAssertion?: ContractAssertionRunner): Promise<void>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { beginContract } from "./contract-harness.js";
|
|
3
|
+
export const MAILBOX_CONTRACT_SCOPE = "default";
|
|
4
|
+
const msg = (content, sentAt = 1000) => ({ content, sentAt });
|
|
5
|
+
async function withStores(mk, fn) {
|
|
6
|
+
const live = [];
|
|
7
|
+
const make = () => {
|
|
8
|
+
const s = mk();
|
|
9
|
+
live.push(s);
|
|
10
|
+
return s;
|
|
11
|
+
};
|
|
12
|
+
try {
|
|
13
|
+
await fn(make);
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
for (const s of live.splice(0))
|
|
17
|
+
s.close?.();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function mailboxStoreContract(mk, runAssertion) {
|
|
21
|
+
const { run: runRaw, settle } = beginContract(runAssertion);
|
|
22
|
+
const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
|
|
23
|
+
const S = MAILBOX_CONTRACT_SCOPE;
|
|
24
|
+
run("append 铸单调 seq;claimLease 取全部可见消息(不删);ack 才删;活租约=单消费者", async (make) => {
|
|
25
|
+
const s = make();
|
|
26
|
+
assert.equal(await s.append(S, "a1", msg("m1")), 1);
|
|
27
|
+
assert.equal(await s.append(S, "a1", msg("m2")), 2);
|
|
28
|
+
assert.equal(await s.peekCount(S, "a1"), 2);
|
|
29
|
+
const lease = await s.claimLease(S, "a1", "w1", 60_000, 10_000);
|
|
30
|
+
assert.deepEqual(lease.messages.map((m) => m.content), ["m1", "m2"]);
|
|
31
|
+
assert.equal(lease.maxSeq, 2);
|
|
32
|
+
assert.equal(await s.claimLease(S, "a1", "w2", 60_000, 20_000), null);
|
|
33
|
+
assert.equal(await s.peekCount(S, "a1"), 2);
|
|
34
|
+
await s.ack(S, "a1", "w1", 2);
|
|
35
|
+
assert.equal(await s.peekCount(S, "a1"), 0);
|
|
36
|
+
});
|
|
37
|
+
run("X-1 无损:lease 过期/release 后消息按原 seq 回可见;seq 在箱的一生中永不复用", async (make) => {
|
|
38
|
+
const s = make();
|
|
39
|
+
await s.append(S, "a1", msg("m1"));
|
|
40
|
+
const l1 = await s.claimLease(S, "a1", "w1", 1_000, 10_000);
|
|
41
|
+
assert.equal(l1.maxSeq, 1);
|
|
42
|
+
const l2 = await s.claimLease(S, "a1", "w2", 1_000, 20_000);
|
|
43
|
+
assert.deepEqual(l2.messages.map((m) => m.seq), [1]);
|
|
44
|
+
await s.releaseLease(S, "a1", "w2");
|
|
45
|
+
const l3 = await s.claimLease(S, "a1", "w3", 1_000, 20_500);
|
|
46
|
+
assert.deepEqual(l3.messages.map((m) => m.seq), [1]);
|
|
47
|
+
await s.ack(S, "a1", "w3", 1);
|
|
48
|
+
assert.equal(await s.append(S, "a1", msg("m2")), 2);
|
|
49
|
+
});
|
|
50
|
+
run("lease 窗内 append:后到消息在下次 claim 时可见(lease 只覆盖 claim 时刻快照)", async (make) => {
|
|
51
|
+
const s = make();
|
|
52
|
+
await s.append(S, "a1", msg("m1"));
|
|
53
|
+
const l = await s.claimLease(S, "a1", "w1", 60_000, 10_000);
|
|
54
|
+
assert.equal(l.maxSeq, 1);
|
|
55
|
+
await s.append(S, "a1", msg("m2"));
|
|
56
|
+
await s.ack(S, "a1", "w1", l.maxSeq);
|
|
57
|
+
assert.equal(await s.peekCount(S, "a1"), 1);
|
|
58
|
+
const next = await s.claimLease(S, "a1", "w2", 60_000, 20_000);
|
|
59
|
+
assert.deepEqual(next.messages.map((m) => m.content), ["m2"]);
|
|
60
|
+
});
|
|
61
|
+
run("drop 清整箱;reap 按最新消息龄整箱老化", async (make) => {
|
|
62
|
+
const s = make();
|
|
63
|
+
await s.append(S, "a1", msg("old", 1_000));
|
|
64
|
+
await s.append(S, "a2", msg("fresh", 90_000));
|
|
65
|
+
assert.equal(await s.reap(S, 100_000, { maxAgeMs: 50_000 }), 1);
|
|
66
|
+
assert.equal(await s.peekCount(S, "a1"), 0);
|
|
67
|
+
assert.equal(await s.peekCount(S, "a2"), 1);
|
|
68
|
+
await s.drop(S, "a2");
|
|
69
|
+
assert.equal(await s.peekCount(S, "a2"), 0);
|
|
70
|
+
});
|
|
71
|
+
run("RB-86 空箱 ⇒ null:没消息就不发租约(既不假装投递,也不 fence 掉后来真正的消费者)", async (make) => {
|
|
72
|
+
const s = make();
|
|
73
|
+
assert.equal(await s.claimLease(S, "never-written", "o1", 60_000, 1_000), null, "从未写过的箱");
|
|
74
|
+
await s.append(S, "a1", msg("m1"));
|
|
75
|
+
const first = await s.claimLease(S, "a1", "o1", 60_000, 1_000);
|
|
76
|
+
await s.ack(S, "a1", "o1", first.maxSeq);
|
|
77
|
+
assert.equal(await s.peekCount(S, "a1"), 0);
|
|
78
|
+
assert.equal(await s.claimLease(S, "a1", "o1", 60_000, 2_000), null, "全部 ack 后箱内零可见消息");
|
|
79
|
+
assert.equal(await s.claimLease(S, "a2", "ghost", 60_000, 3_000), null);
|
|
80
|
+
await s.append(S, "a2", msg("real", 4_000));
|
|
81
|
+
const real = await s.claimLease(S, "a2", "o2", 60_000, 4_000);
|
|
82
|
+
assert.deepEqual(real?.messages.map((m) => m.content), ["real"], "空取不该留下 fence 后来者的幽灵租约");
|
|
83
|
+
});
|
|
84
|
+
run("RB-261 守恒律:一条消息在被 ack 消费之前必须至少投递过一次(maxSeq 不得覆盖未投递项)", async (make) => {
|
|
85
|
+
const s = make();
|
|
86
|
+
const appended = ["m1", "m2", "m3"];
|
|
87
|
+
for (const [i, c] of appended.entries())
|
|
88
|
+
await s.append(S, "a1", msg(c, 1_000 + i));
|
|
89
|
+
const delivered = new Set();
|
|
90
|
+
let round = 0;
|
|
91
|
+
while ((await s.peekCount(S, "a1")) > 0) {
|
|
92
|
+
round += 1;
|
|
93
|
+
assert.ok(round < 10, "要么有限轮内收敛,要么就是投递不动了");
|
|
94
|
+
const owner = `w${round}`;
|
|
95
|
+
const lease = await s.claimLease(S, "a1", owner, 60_000, 10_000 + round);
|
|
96
|
+
assert.notEqual(lease, null, "还有可见消息时 claimLease 不得返回 null");
|
|
97
|
+
for (const m of lease.messages)
|
|
98
|
+
delivered.add(m.content);
|
|
99
|
+
await s.ack(S, "a1", owner, lease.maxSeq);
|
|
100
|
+
}
|
|
101
|
+
assert.deepEqual([...delivered].sort(), [...appended].sort(), "每条被消费掉的消息都必须先被投递过");
|
|
102
|
+
});
|
|
103
|
+
run("releaseLease owner-fenced:非持有者的 release 是 no-op(活租约的单消费者窗口不被外人撬开)", async (make) => {
|
|
104
|
+
const s = make();
|
|
105
|
+
await s.append(S, "a1", msg("m1"));
|
|
106
|
+
const held = await s.claimLease(S, "a1", "owner-A", 60_000, 10_000);
|
|
107
|
+
assert.equal(held.messages.length, 1);
|
|
108
|
+
await s.releaseLease(S, "a1", "not-the-owner");
|
|
109
|
+
assert.equal(await s.claimLease(S, "a1", "rival", 60_000, 11_000), null, "A 的活租约必须仍然 fence 住 rival");
|
|
110
|
+
await s.releaseLease(S, "a1", "owner-A");
|
|
111
|
+
const after = await s.claimLease(S, "a1", "rival", 60_000, 12_000);
|
|
112
|
+
assert.deepEqual(after?.messages.map((m) => m.seq), [1], "归还后同一条按原 seq 回可见");
|
|
113
|
+
});
|
|
114
|
+
await settle();
|
|
115
|
+
}
|
|
116
|
+
export async function mailboxAckOwnershipContract(mk, runAssertion) {
|
|
117
|
+
const { run: runRaw, settle } = beginContract(runAssertion);
|
|
118
|
+
const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
|
|
119
|
+
const S = MAILBOX_CONTRACT_SCOPE;
|
|
120
|
+
run("a non-holder's ack does not delete messages held under another owner's LIVE lease", async (make) => {
|
|
121
|
+
const s = make();
|
|
122
|
+
await s.append(S, "a1", { content: "m1", sentAt: 1 });
|
|
123
|
+
await s.append(S, "a1", { content: "m2", sentAt: 2 });
|
|
124
|
+
const lease = await s.claimLease(S, "a1", "consumer-A", 60_000, 10_000);
|
|
125
|
+
assert.equal(lease?.messages.length, 2);
|
|
126
|
+
await s.ack(S, "a1", "stale-actor", lease.maxSeq);
|
|
127
|
+
assert.equal(await s.peekCount(S, "a1"), 2, "the messages must survive — this caller never held the lease");
|
|
128
|
+
});
|
|
129
|
+
run("angle 4 — internal consistency: releaseLease and ack now apply the SAME owner fence (was the asymmetry that proved this was an oversight)", async (make) => {
|
|
130
|
+
const s = make();
|
|
131
|
+
await s.append(S, "a1", { content: "m1", sentAt: 1 });
|
|
132
|
+
await s.claimLease(S, "a1", "real-owner", 60_000, 10_000);
|
|
133
|
+
await s.releaseLease(S, "a1", "wrong-owner");
|
|
134
|
+
await s.ack(S, "a1", "wrong-owner", 1);
|
|
135
|
+
assert.equal(await s.peekCount(S, "a1"), 1, "neither operation should have touched anything");
|
|
136
|
+
});
|
|
137
|
+
run("angle 5 — ack with no lease EVER claimed is a no-op, not a silent wipe", async (make) => {
|
|
138
|
+
const s = make();
|
|
139
|
+
await s.append(S, "a1", { content: "m1", sentAt: 1 });
|
|
140
|
+
await s.ack(S, "a1", "nobody", Number.MAX_SAFE_INTEGER);
|
|
141
|
+
assert.equal(await s.peekCount(S, "a1"), 1);
|
|
142
|
+
});
|
|
143
|
+
run("the legitimate holder's own ack still works (regression check — the fix must not break the happy path)", async (make) => {
|
|
144
|
+
const s = make();
|
|
145
|
+
await s.append(S, "a1", { content: "m1", sentAt: 1 });
|
|
146
|
+
await s.append(S, "a1", { content: "m2", sentAt: 2 });
|
|
147
|
+
const lease = await s.claimLease(S, "a1", "consumer-A", 60_000, 10_000);
|
|
148
|
+
await s.ack(S, "a1", "consumer-A", lease.maxSeq);
|
|
149
|
+
assert.equal(await s.peekCount(S, "a1"), 0);
|
|
150
|
+
});
|
|
151
|
+
run("angle 3 in detail — the real loss sequence (stale owner outlives its own expired lease, a NEW owner has since taken over): the new owner's custody survives", async (make) => {
|
|
152
|
+
const s = make();
|
|
153
|
+
await s.append(S, "a1", { content: "ship-the-release", sentAt: 1 });
|
|
154
|
+
await s.append(S, "a1", { content: "notify-oncall", sentAt: 2 });
|
|
155
|
+
const leaseA = await s.claimLease(S, "a1", "consumer-A", 1_000, 1_000);
|
|
156
|
+
assert.equal(leaseA?.messages.length, 2);
|
|
157
|
+
const leaseB = await s.claimLease(S, "a1", "consumer-B", 60_000, 5_000);
|
|
158
|
+
assert.equal(leaseB?.messages.length, 2);
|
|
159
|
+
await s.ack(S, "a1", "consumer-A", leaseA.maxSeq);
|
|
160
|
+
assert.equal(await s.peekCount(S, "a1"), 2, "B's live custody must survive A's stale ack");
|
|
161
|
+
await s.ack(S, "a1", "consumer-B", leaseB.maxSeq);
|
|
162
|
+
assert.equal(await s.peekCount(S, "a1"), 0);
|
|
163
|
+
});
|
|
164
|
+
await settle();
|
|
165
|
+
}
|
|
166
|
+
export async function mailboxBundledOnlyContract(mk, runAssertion) {
|
|
167
|
+
const { run: runRaw, settle } = beginContract(runAssertion);
|
|
168
|
+
const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
|
|
169
|
+
run("RB-251(黑板 [1937]):handle 含 NUL 被两个后端一致拒绝,且不得跨 (scope,handle) 串箱", async (make) => {
|
|
170
|
+
const s = make();
|
|
171
|
+
const NUL = String.fromCharCode(0);
|
|
172
|
+
await assert.rejects(s.append("s", `a${NUL}b`, msg("secret")));
|
|
173
|
+
assert.equal(await s.peekCount(`s${NUL}a`, "b"), 0, "歧义键下不得出现串箱可读");
|
|
174
|
+
await s.append("s", "plain", msg("ok"));
|
|
175
|
+
assert.equal(await s.peekCount("s", "plain"), 1);
|
|
176
|
+
});
|
|
177
|
+
run("多租户:同名 handle 在不同 scope 下是两个互不可见的箱(各自独立铸 seq)", async (make) => {
|
|
178
|
+
const s = make();
|
|
179
|
+
assert.equal(await s.append("tenant-a", "shared-handle", msg("for-a")), 1);
|
|
180
|
+
assert.equal(await s.append("tenant-b", "shared-handle", msg("for-b")), 1, "另一个 scope 的箱独立铸号");
|
|
181
|
+
assert.equal(await s.peekCount("tenant-a", "shared-handle"), 1);
|
|
182
|
+
const a = await s.claimLease("tenant-a", "shared-handle", "w", 60_000, 10_000);
|
|
183
|
+
assert.deepEqual(a.messages.map((m) => m.content), ["for-a"]);
|
|
184
|
+
const b = await s.claimLease("tenant-b", "shared-handle", "w", 60_000, 10_000);
|
|
185
|
+
assert.deepEqual(b.messages.map((m) => m.content), ["for-b"]);
|
|
186
|
+
await s.ack("tenant-a", "shared-handle", "w", a.maxSeq);
|
|
187
|
+
assert.equal(await s.peekCount("tenant-a", "shared-handle"), 0);
|
|
188
|
+
assert.equal(await s.peekCount("tenant-b", "shared-handle"), 1, "跨 scope 的 ack 不得越界");
|
|
189
|
+
await s.drop("tenant-b", "shared-handle");
|
|
190
|
+
assert.equal(await s.peekCount("tenant-b", "shared-handle"), 0);
|
|
191
|
+
});
|
|
192
|
+
await settle();
|
|
193
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { beginContract } from "./contract-harness.js";
|
|
3
|
+
const SESSION_ENTRY = (id, parentId) => ({
|
|
4
|
+
id,
|
|
5
|
+
parentId,
|
|
6
|
+
type: "message",
|
|
7
|
+
timestamp: "2026-07-30T00:00:00.000Z",
|
|
8
|
+
message: { role: "user", content: [{ type: "text", text: `t-${id}` }], timestamp: 1785340800000 },
|
|
9
|
+
});
|
|
10
|
+
export async function sessionRepoContract(make, runAssertion) {
|
|
11
|
+
const { run, settle } = beginContract(runAssertion);
|
|
12
|
+
const A = "0198fa00-0000-7000-8000-00000000000a";
|
|
13
|
+
run("RB-360 create({id}) on an id that ALREADY has history is an idempotent REOPEN — never a wipe", async () => {
|
|
14
|
+
const repo = make();
|
|
15
|
+
const sess = await repo.create({ id: A });
|
|
16
|
+
await sess.getStorage().appendEntry(SESSION_ENTRY("e1", null));
|
|
17
|
+
await sess.getStorage().appendEntry(SESSION_ENTRY("e2", "e1"));
|
|
18
|
+
const again = await repo.create({ id: A });
|
|
19
|
+
const entries = await again.getStorage().getEntries();
|
|
20
|
+
assert.deepEqual(entries.map((e) => e.id), ["e1", "e2"]);
|
|
21
|
+
const exported = (await repo.exportEntries?.(A)) ?? [];
|
|
22
|
+
assert.deepEqual(exported.map((e) => e.id), ["e1", "e2"]);
|
|
23
|
+
});
|
|
24
|
+
run("create({id}) fresh → open() sees appended entries (round-trip floor)", async () => {
|
|
25
|
+
const repo = make();
|
|
26
|
+
const sess = await repo.create({ id: A });
|
|
27
|
+
await sess.getStorage().appendEntry(SESSION_ENTRY("e1", null));
|
|
28
|
+
const opened = await repo.open({ id: A, createdAt: "" });
|
|
29
|
+
assert.deepEqual((await opened.getStorage().getEntries()).map((e) => e.id), ["e1"]);
|
|
30
|
+
});
|
|
31
|
+
run("open(missing) throws not_found — never a silent create", async () => {
|
|
32
|
+
const repo = make();
|
|
33
|
+
await assert.rejects(repo.open({ id: "0198fa00-0000-7000-8000-0000000000ff", createdAt: "" }), /not.?found/i);
|
|
34
|
+
});
|
|
35
|
+
await settle();
|
|
36
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { beginContract } from "./contract-harness.js";
|
|
3
|
+
export async function toolResultStoreContract(make, runAssertion) {
|
|
4
|
+
const { run, settle } = beginContract(runAssertion);
|
|
5
|
+
run("put/get full + sliced; write-once no-op; unknown → undefined", async () => {
|
|
6
|
+
const store = make();
|
|
7
|
+
const ref = "tr_s_c";
|
|
8
|
+
await store.put(ref, "0123456789");
|
|
9
|
+
assert.deepEqual(await store.get(ref), { content: "0123456789", offset: 0, totalChars: 10 });
|
|
10
|
+
assert.deepEqual(await store.get(ref, { offset: 2, limit: 3 }), { content: "234", offset: 2, totalChars: 10 });
|
|
11
|
+
await store.put(ref, "IGNORED");
|
|
12
|
+
assert.equal((await store.get(ref)).content, "0123456789");
|
|
13
|
+
assert.equal(await store.get("tr_unknown"), undefined);
|
|
14
|
+
});
|
|
15
|
+
run("RB-266 unsafe refs are rejected at the WRITE by every backend; the read face degrades instead", async () => {
|
|
16
|
+
const store = make();
|
|
17
|
+
const NUL = String.fromCharCode(0);
|
|
18
|
+
const unsafe = ["", ".", "..", "a/b", "../escape", "dir\\ref", `tr_x${NUL}y`];
|
|
19
|
+
for (const ref of unsafe) {
|
|
20
|
+
await assert.rejects((async () => store.put(ref, "payload"))(), `put(${JSON.stringify(ref)}) must reject — a durable backend cannot key it`);
|
|
21
|
+
assert.equal(await store.get(ref), undefined, "get of an unsafe ref degrades to undefined");
|
|
22
|
+
}
|
|
23
|
+
await store.put("tr_sess-1_call.7", "ok");
|
|
24
|
+
assert.equal((await store.get("tr_sess-1_call.7")).content, "ok");
|
|
25
|
+
});
|
|
26
|
+
run("RB-273 a ref the contract ACCEPTS round-trips on every backend (no backend is stricter than the rule)", async () => {
|
|
27
|
+
const store = make();
|
|
28
|
+
for (const ref of ["tr_sess_call:1", "tr_sess_call%2F1", "tr_sess_call 1", "tr_sess_日本語"]) {
|
|
29
|
+
await store.put(ref, `payload-${ref}`);
|
|
30
|
+
assert.equal((await store.get(ref))?.content, `payload-${ref}`, `put(${JSON.stringify(ref)}) must round-trip here`);
|
|
31
|
+
}
|
|
32
|
+
assert.equal((await store.get("tr_sess_call:1")).content, "payload-tr_sess_call:1");
|
|
33
|
+
});
|
|
34
|
+
await settle();
|
|
35
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type BackgroundAgentRecord, type BackgroundAgentStore } from "./background-agent-store.js";
|
|
2
2
|
import { type StopSource, type TaskAccess, type UnifiedTaskResult, type BackgroundAgentTaskHandle, type DurableAgentCore, type ParkedClaimTicket, type RegisterBackgroundAgentInput } from "./task-registry-shared.js";
|
|
3
|
+
import { type ToolResultStore } from "./tool-result-store.js";
|
|
3
4
|
export declare function ensureDurableHeartbeatLane(core: DurableAgentCore): void;
|
|
4
5
|
export declare function durableAgentWriteLane(handle: BackgroundAgentTaskHandle, patch: Partial<BackgroundAgentRecord>, clear?: (keyof BackgroundAgentRecord)[]): void;
|
|
5
6
|
export declare function durableAgentArmedLane(core: DurableAgentCore, id: string): boolean;
|
|
@@ -65,6 +66,8 @@ export declare function settleBackgroundAgentLane(core: DurableAgentCore, id: st
|
|
|
65
66
|
result?: string;
|
|
66
67
|
resultFull?: string;
|
|
67
68
|
error?: string;
|
|
69
|
+
errorCode?: string;
|
|
70
|
+
retryable?: boolean;
|
|
68
71
|
stoppedBy?: StopSource;
|
|
69
72
|
seq?: number;
|
|
70
73
|
}): "completed" | "failed" | "killed" | undefined;
|
|
@@ -97,6 +100,8 @@ export declare function settleRevivedAgentLane(core: DurableAgentCore, id: strin
|
|
|
97
100
|
result?: string;
|
|
98
101
|
resultFull?: string;
|
|
99
102
|
error?: string;
|
|
103
|
+
errorCode?: string;
|
|
104
|
+
retryable?: boolean;
|
|
100
105
|
}): "completed" | "failed" | "killed" | undefined;
|
|
101
106
|
export declare function unmarkRetainedContinuationLane(core: DurableAgentCore, id: string): void;
|
|
102
107
|
export declare function attachAgentNotifyLane(core: DurableAgentCore, id: string, notify: NonNullable<BackgroundAgentTaskHandle["notify"]>, cycle?: number): void;
|
|
@@ -119,5 +124,6 @@ export declare function notFoundRunningAgentsTail(footer: {
|
|
|
119
124
|
background: string[];
|
|
120
125
|
}): string;
|
|
121
126
|
export declare function serveDurableAgentRowLane(row: BackgroundAgentRecord): UnifiedTaskResult;
|
|
122
|
-
export declare function
|
|
127
|
+
export declare function spillClippedAgentResult(handle: BackgroundAgentTaskHandle, full: string, clipped: string, store: ToolResultStore | undefined, sessionId: string | undefined): Promise<string>;
|
|
128
|
+
export declare function pollBackgroundAgentLane(handle: BackgroundAgentTaskHandle, deadline?: number, signal?: AbortSignal, oneShot?: boolean, store?: ToolResultStore, sessionId?: string): Promise<UnifiedTaskResult>;
|
|
123
129
|
export declare function stopBackgroundAgentLane(core: DurableAgentCore, handle: BackgroundAgentTaskHandle): Promise<UnifiedTaskResult>;
|
|
@@ -3,7 +3,9 @@ import { uuidv7 } from "../internal/harness.js";
|
|
|
3
3
|
import { canAccessAgentRecord, BackgroundAgentStoreError, } from "./background-agent-store.js";
|
|
4
4
|
import { shutdownDebug } from "./shutdown-debug.js";
|
|
5
5
|
import { delimitUntrusted } from "./untrusted-text.js";
|
|
6
|
+
import { boundedRedactedSummary } from "./untrusted-egress.js";
|
|
6
7
|
import { mintCompletionId, commitCompletionIdIfEmpty, clipTaskOutput, assertOwnership, sleepPollStep, alreadyTerminalStopNote, canAccess, normalizeAgentName, closestName, DURABLE_AGENT_HEARTBEAT_MS, DURABLE_AGENT_HANDLE_RE, BG_AGENT_REAP_STOP_ERROR, } from "./task-registry-shared.js";
|
|
8
|
+
import { buildToolResultRef, OFFLOAD_TOOL_NAME } from "./tool-result-store.js";
|
|
7
9
|
export function ensureDurableHeartbeatLane(core) {
|
|
8
10
|
if (core.durableHeartbeatTimer !== undefined)
|
|
9
11
|
return;
|
|
@@ -635,6 +637,7 @@ export async function consumeParkedFlipLane(core, id, stores, ticket) {
|
|
|
635
637
|
handle.resolveParkedStop = undefined;
|
|
636
638
|
handle.updatedAt = Date.now();
|
|
637
639
|
handle.completionId = undefined;
|
|
640
|
+
handle.terminalNotified = undefined;
|
|
638
641
|
if (flipped.seq !== undefined)
|
|
639
642
|
handle.cycleSeq = flipped.seq;
|
|
640
643
|
return true;
|
|
@@ -680,6 +683,7 @@ export function settleBackgroundAgentLane(core, id, outcome) {
|
|
|
680
683
|
handle.status = outcome.status;
|
|
681
684
|
mintCompletionId(handle);
|
|
682
685
|
handle.notify = undefined;
|
|
686
|
+
handle.onReapTerminal = undefined;
|
|
683
687
|
for (const [, , qResolve] of handle.preAttachQueue ?? [])
|
|
684
688
|
qResolve?.({ ok: false, reason: "not_running" });
|
|
685
689
|
handle.preAttachQueue = undefined;
|
|
@@ -698,6 +702,10 @@ export function settleBackgroundAgentLane(core, id, outcome) {
|
|
|
698
702
|
}
|
|
699
703
|
if (outcome.error !== undefined && !(outcome.status === "killed" && outcome.error === BG_AGENT_REAP_STOP_ERROR)) {
|
|
700
704
|
handle.error = outcome.error;
|
|
705
|
+
if (outcome.errorCode !== undefined)
|
|
706
|
+
handle.errorCode = outcome.errorCode;
|
|
707
|
+
if (outcome.retryable !== undefined)
|
|
708
|
+
handle.errorRetryable = outcome.retryable;
|
|
701
709
|
}
|
|
702
710
|
handle.updatedAt = Date.now();
|
|
703
711
|
if (outcome.seq !== undefined)
|
|
@@ -834,10 +842,12 @@ export function reviveBackgroundAgentLane(core, id, access, abort) {
|
|
|
834
842
|
handle.abort = abort;
|
|
835
843
|
handle.result = undefined;
|
|
836
844
|
handle.resultFull = undefined;
|
|
845
|
+
handle.spillRef = undefined;
|
|
837
846
|
handle.error = undefined;
|
|
838
847
|
handle.resultIsPartial = undefined;
|
|
839
848
|
handle.stopSource = undefined;
|
|
840
849
|
handle.completionId = undefined;
|
|
850
|
+
handle.terminalNotified = undefined;
|
|
841
851
|
handle.reviveCycle = (handle.reviveCycle ?? 0) + 1;
|
|
842
852
|
handle.cycleSeq = (handle.cycleSeq ?? 1) + 1;
|
|
843
853
|
handle.updatedAt = Date.now();
|
|
@@ -1022,7 +1032,19 @@ ${clipTaskOutput(row.finalOutputFull ?? row.finalOutput)}` : "(no result text)"}
|
|
|
1022
1032
|
},
|
|
1023
1033
|
};
|
|
1024
1034
|
}
|
|
1025
|
-
export async function
|
|
1035
|
+
export async function spillClippedAgentResult(handle, full, clipped, store, sessionId) {
|
|
1036
|
+
if (clipped === full)
|
|
1037
|
+
return clipped;
|
|
1038
|
+
if (store === undefined)
|
|
1039
|
+
return clipped;
|
|
1040
|
+
if (handle.spillRef === undefined) {
|
|
1041
|
+
const ref = buildToolResultRef(sessionId ?? "no-session", `${handle.id}_c${handle.reviveCycle ?? 0}`);
|
|
1042
|
+
await store.put(ref, full);
|
|
1043
|
+
handle.spillRef = ref;
|
|
1044
|
+
}
|
|
1045
|
+
return `${clipped}\n\n[full output persisted — call ${OFFLOAD_TOOL_NAME} with ref "${handle.spillRef}" to read it back.]`;
|
|
1046
|
+
}
|
|
1047
|
+
export async function pollBackgroundAgentLane(handle, deadline, signal, oneShot, store, sessionId) {
|
|
1026
1048
|
while (handle.status === "running" && deadline !== undefined && Date.now() < deadline && !signal?.aborted) {
|
|
1027
1049
|
await sleepPollStep(deadline, signal);
|
|
1028
1050
|
}
|
|
@@ -1041,6 +1063,8 @@ The agent is durably suspended, waiting for an approval decision. It resumes whe
|
|
|
1041
1063
|
},
|
|
1042
1064
|
};
|
|
1043
1065
|
}
|
|
1066
|
+
const fullResult = handle.result ? (handle.resultFull ?? handle.result) : undefined;
|
|
1067
|
+
const resultText = fullResult !== undefined ? await spillClippedAgentResult(handle, fullResult, clipTaskOutput(fullResult, handle.outputFile), store, sessionId) : undefined;
|
|
1044
1068
|
const body = running
|
|
1045
1069
|
? oneShot === true
|
|
1046
1070
|
? `status: running
|
|
@@ -1050,7 +1074,7 @@ The agent is still working — you will be notified when it completes.`
|
|
|
1050
1074
|
: `status: ${handle.status}
|
|
1051
1075
|
${handle.error ? `error: ${handle.error}
|
|
1052
1076
|
` : ""}${handle.result ? `--- result${handle.resultIsPartial ? " (partial — produced before the task was stopped)" : ""} ---
|
|
1053
|
-
${
|
|
1077
|
+
${resultText}` : "(no result text)"}`;
|
|
1054
1078
|
return {
|
|
1055
1079
|
content: delimitUntrusted(`TaskOutput ${handle.id}`, body),
|
|
1056
1080
|
details: {
|
|
@@ -1060,6 +1084,11 @@ ${clipTaskOutput(handle.resultFull ?? handle.result, handle.outputFile)}` : "(no
|
|
|
1060
1084
|
retrieval_status: retrieval,
|
|
1061
1085
|
...(handle.cycleSeq !== undefined ? { seq: handle.cycleSeq } : {}),
|
|
1062
1086
|
...(handle.status === "killed" && handle.stoppedBy !== undefined ? { stoppedBy: handle.stoppedBy } : {}),
|
|
1087
|
+
...(handle.status === "failed" && handle.error !== undefined
|
|
1088
|
+
? { error: delimitUntrusted("agent error", boundedRedactedSummary(handle.error, 300)) }
|
|
1089
|
+
: {}),
|
|
1090
|
+
...(handle.status === "failed" && handle.errorCode !== undefined ? { errorCode: handle.errorCode } : {}),
|
|
1091
|
+
...(handle.status === "failed" && handle.errorRetryable !== undefined ? { retryable: handle.errorRetryable } : {}),
|
|
1063
1092
|
...(handle.resultIsPartial ? { partial_result: true } : {}),
|
|
1064
1093
|
...(handle.completionId !== undefined ? { completionId: handle.completionId } : {}),
|
|
1065
1094
|
},
|