@sema-agent/core 5.21.0 → 5.22.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/CHANGELOG.md +66 -0
- package/dist/agents/send-message-tool.js +6 -3
- package/dist/agents/subagent.d.ts +6 -0
- package/dist/agents/subagent.js +45 -4
- package/dist/brain/errors.d.ts +20 -0
- package/dist/brain/errors.js +40 -0
- package/dist/brain/retry.d.ts +16 -2
- package/dist/brain/retry.js +3 -2
- package/dist/brain/status-sink.d.ts +9 -2
- package/dist/brain/stream-engine.d.ts +22 -0
- package/dist/brain/stream-engine.js +41 -10
- package/dist/core/ask-class.d.ts +48 -0
- package/dist/core/ask-class.js +33 -0
- package/dist/core/checkpoint-store.d.ts +103 -10
- package/dist/core/checkpoint-store.js +3 -1
- package/dist/core/governance-codes.d.ts +38 -0
- package/dist/core/governance-codes.js +11 -0
- package/dist/core/hooks.d.ts +39 -0
- package/dist/core/hooks.js +26 -2
- package/dist/core/locked-config.d.ts +7 -1
- package/dist/core/locked-config.js +2 -1
- package/dist/core/memory-engine/delegation-provenance.d.ts +62 -0
- package/dist/core/memory-engine/delegation-provenance.js +26 -0
- package/dist/core/memory-engine/engine.d.ts +67 -1
- package/dist/core/memory-engine/engine.js +270 -12
- package/dist/core/memory-engine/header-hints.d.ts +30 -0
- package/dist/core/memory-engine/header-hints.js +41 -0
- package/dist/core/memory-engine/index.d.ts +3 -2
- package/dist/core/memory-engine/index.js +3 -2
- package/dist/core/memory-engine/layout.d.ts +166 -0
- package/dist/core/memory-engine/layout.js +399 -0
- package/dist/core/memory-engine/tools.d.ts +30 -0
- package/dist/core/memory-engine/tools.js +108 -17
- package/dist/core/permission-rule-consent.d.ts +25 -9
- package/dist/core/permission-rule-consent.js +91 -20
- package/dist/core/permission-rule-model.d.ts +9 -1
- package/dist/core/permission-rule-model.js +2 -2
- package/dist/core/permission-rule-org.d.ts +161 -0
- package/dist/core/permission-rule-org.js +211 -0
- package/dist/core/permission-rule-store.d.ts +249 -6
- package/dist/core/permission-rule-store.js +313 -3
- package/dist/core/permission-rule-sync.d.ts +131 -0
- package/dist/core/permission-rule-sync.js +314 -0
- package/dist/core/runner/prepare-memory.js +35 -8
- package/dist/core/runner/prepare-task.d.ts +54 -1
- package/dist/core/runner/prepare-task.js +246 -27
- package/dist/core/runner/runtask.js +147 -6
- package/dist/core/shared-memory/contract.js +19 -4
- package/dist/core/shared-memory/normalize.d.ts +3 -1
- package/dist/core/shared-memory/tools.js +73 -17
- package/dist/core/shared-memory/types.d.ts +27 -1
- package/dist/core/store-contracts/permission-rule-sync-contract.d.ts +33 -0
- package/dist/core/store-contracts/permission-rule-sync-contract.js +186 -0
- package/dist/core/task-notification.d.ts +5 -2
- package/dist/core/task-registry-agent.d.ts +1 -1
- package/dist/core/task-registry-agent.js +6 -2
- package/dist/core/task-registry-shared.d.ts +9 -2
- package/dist/core/task-registry.d.ts +9 -3
- package/dist/core/task-registry.js +2 -0
- package/dist/core/tool-policy.d.ts +120 -2
- package/dist/core/tool-policy.js +116 -6
- package/dist/core/trace.d.ts +32 -1
- package/dist/core/types.d.ts +56 -3
- package/dist/index.d.ts +12 -7
- package/dist/index.js +10 -5
- package/dist/stores/file/checkpoint-store.d.ts +4 -0
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/permission-rule-adopt.d.ts +62 -0
- package/dist/stores/file/permission-rule-adopt.js +95 -0
- package/dist/stores/file/permission-rule-store.d.ts +80 -2
- package/dist/stores/file/permission-rule-store.js +189 -46
- package/dist/tools/fs/fs-search-tools.js +0 -1
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { collectBelowFrontier, joinRuleStates, ruleSyncVector } from "../permission-rule-store.js";
|
|
3
|
+
import { beginContract } from "./contract-harness.js";
|
|
4
|
+
const GLOBAL = { kind: "global" };
|
|
5
|
+
function add(actor, counter, origin = "user") {
|
|
6
|
+
return { dot: { actor, counter }, origin, createdAt: "2026-01-01T00:00:00.000Z" };
|
|
7
|
+
}
|
|
8
|
+
function rule(text, adds, scope = GLOBAL) {
|
|
9
|
+
const m = /^Bash\((.+?)(:\*)?\)$/.exec(text);
|
|
10
|
+
if (m === null || m[1] === undefined)
|
|
11
|
+
throw new Error(`contract fixture rule "${text}" is not a Bash(...) form`);
|
|
12
|
+
return { rule: text, tool: "Bash", match: m[2] !== undefined ? "prefix" : "exact", command: m[1], scope, adds };
|
|
13
|
+
}
|
|
14
|
+
function tomb(text, removed, deletedBy, scope = GLOBAL) {
|
|
15
|
+
return {
|
|
16
|
+
rule: text,
|
|
17
|
+
scope,
|
|
18
|
+
removedDots: removed.map(([actor, counter]) => ({ actor, counter })),
|
|
19
|
+
deletedBy: { actor: deletedBy[0], counter: deletedBy[1] },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function state(rules = [], tombstones = []) {
|
|
23
|
+
return { rules, tombstones };
|
|
24
|
+
}
|
|
25
|
+
function liveDots(s) {
|
|
26
|
+
const out = new Set();
|
|
27
|
+
for (const r of s.rules) {
|
|
28
|
+
const removed = s.tombstones.filter((t) => t.rule === r.rule && sameScope(t.scope, r.scope)).flatMap((t) => t.removedDots);
|
|
29
|
+
for (const a of r.adds) {
|
|
30
|
+
if (!removed.some((d) => d.actor === a.dot.actor && d.counter === a.dot.counter))
|
|
31
|
+
out.add(`${r.rule}|${a.dot.actor}#${a.dot.counter}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function sameScope(a, b) {
|
|
37
|
+
return a.kind === "global" ? b.kind === "global" : b.kind === "project" && a.root === b.root;
|
|
38
|
+
}
|
|
39
|
+
function allDots(s) {
|
|
40
|
+
return new Set(s.rules.flatMap((r) => r.adds.map((a) => `${r.rule}|${a.dot.actor}#${a.dot.counter}`)));
|
|
41
|
+
}
|
|
42
|
+
export async function permissionRuleSyncContract(hooks = {}) {
|
|
43
|
+
const join = hooks.join ?? joinRuleStates;
|
|
44
|
+
const collect = hooks.collect ?? collectBelowFrontier;
|
|
45
|
+
const vector = hooks.vector ?? ruleSyncVector;
|
|
46
|
+
const { run, settle } = beginContract(hooks.runAssertion);
|
|
47
|
+
const samples = () => [
|
|
48
|
+
state(),
|
|
49
|
+
state([rule("Bash(ls)", [add("a", 1)])]),
|
|
50
|
+
state([rule("Bash(ls)", [add("b", 1)]), rule("Bash(git status)", [add("b", 2)])]),
|
|
51
|
+
state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]),
|
|
52
|
+
state([rule("Bash(pwd)", [add("c", 5)], { kind: "project", root: "/repo" })], [tomb("Bash(date)", [["d", 9]], ["c", 6])]),
|
|
53
|
+
];
|
|
54
|
+
run("join is idempotent: a ⊔ a = a (M11)", async () => {
|
|
55
|
+
for (const s of samples())
|
|
56
|
+
assert.deepStrictEqual(join(s, s), join(s, join(s, s)), "a ⊔ a must equal (a ⊔ a) ⊔ a");
|
|
57
|
+
for (const s of samples())
|
|
58
|
+
assert.deepStrictEqual(liveDots(join(s, s)), liveDots(s), "a ⊔ a must not change the live view");
|
|
59
|
+
});
|
|
60
|
+
run("join is commutative: a ⊔ b = b ⊔ a", async () => {
|
|
61
|
+
const pool = samples();
|
|
62
|
+
for (const a of pool)
|
|
63
|
+
for (const b of pool)
|
|
64
|
+
assert.deepStrictEqual(join(a, b), join(b, a));
|
|
65
|
+
});
|
|
66
|
+
run("join is associative and order/repetition independent (M12)", async () => {
|
|
67
|
+
const [, a, b, c] = samples();
|
|
68
|
+
assert.ok(a && b && c);
|
|
69
|
+
const one = join(join(a, b), c);
|
|
70
|
+
const two = join(a, join(b, c));
|
|
71
|
+
const three = join(join(c, a), join(b, a));
|
|
72
|
+
assert.deepStrictEqual(one, two);
|
|
73
|
+
assert.deepStrictEqual(one, three);
|
|
74
|
+
});
|
|
75
|
+
run("M1: a single-sided add propagates", async () => {
|
|
76
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1)])]), state());
|
|
77
|
+
assert.ok(liveDots(merged).has("Bash(ls)|a#1"));
|
|
78
|
+
});
|
|
79
|
+
run("M2: a multi-dot delete covers every observed dot", async () => {
|
|
80
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1), add("a", 2)])], [tomb("Bash(ls)", [["a", 1], ["a", 2]], ["a", 3])]), state());
|
|
81
|
+
assert.strictEqual(liveDots(merged).size, 0, "both observed dots must be dead");
|
|
82
|
+
assert.strictEqual(merged.tombstones.length, 1);
|
|
83
|
+
assert.strictEqual(merged.tombstones[0]?.removedDots.length, 2, "the tombstone must carry BOTH observed dots");
|
|
84
|
+
});
|
|
85
|
+
run("M3: concurrent approvals of the same text coexist under their own dots", async () => {
|
|
86
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1)])]), state([rule("Bash(ls)", [add("b", 1)])]));
|
|
87
|
+
assert.deepStrictEqual(liveDots(merged), new Set(["Bash(ls)|a#1", "Bash(ls)|b#1"]));
|
|
88
|
+
assert.strictEqual(merged.rules.length, 1, "one logical rule, two adds — not two rule rows");
|
|
89
|
+
});
|
|
90
|
+
run("M4: add-wins — an add the deleter never observed survives the tombstone", async () => {
|
|
91
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state([rule("Bash(ls)", [add("b", 7)])]));
|
|
92
|
+
assert.deepStrictEqual(liveDots(merged), new Set(["Bash(ls)|b#7"]), "dot a#1 dead, unobserved b#7 alive");
|
|
93
|
+
});
|
|
94
|
+
run("M5: deletes on both sides union to cover everything", async () => {
|
|
95
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state([rule("Bash(ls)", [add("b", 7)])], [tomb("Bash(ls)", [["b", 7]], ["b", 8])]));
|
|
96
|
+
assert.strictEqual(liveDots(merged).size, 0);
|
|
97
|
+
});
|
|
98
|
+
run("M6: a deliberate re-add under a NEW dot survives the old tombstone", async () => {
|
|
99
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1), add("a", 3)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state());
|
|
100
|
+
assert.deepStrictEqual(liveDots(merged), new Set(["Bash(ls)|a#3"]));
|
|
101
|
+
});
|
|
102
|
+
run("M7: a double delete is idempotent — distinct deletedBy tombstones both stand, semantics equal", async () => {
|
|
103
|
+
const a = state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]);
|
|
104
|
+
const b = state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["b", 5])]);
|
|
105
|
+
const merged = join(a, b);
|
|
106
|
+
assert.strictEqual(merged.tombstones.length, 2, "two delete intents, two identities — neither is dropped");
|
|
107
|
+
assert.strictEqual(liveDots(merged).size, 0);
|
|
108
|
+
assert.deepStrictEqual(join(a, b), join(b, a));
|
|
109
|
+
});
|
|
110
|
+
run("M8: partially-observed deletes union to full coverage — each tombstone honest about its own view", async () => {
|
|
111
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1), add("a", 2)])], [tomb("Bash(ls)", [["a", 1], ["a", 2]], ["a", 3])]), state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["b", 1])]));
|
|
112
|
+
assert.strictEqual(liveDots(merged).size, 0, "the union of observations must cover both dots");
|
|
113
|
+
});
|
|
114
|
+
run("M9: provenance rides each add and follows the surviving dot", async () => {
|
|
115
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1, "imported-cc")])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]), state([rule("Bash(ls)", [add("b", 7, "user")])]));
|
|
116
|
+
const survivor = merged.rules.find((r) => r.rule === "Bash(ls)");
|
|
117
|
+
assert.ok(survivor);
|
|
118
|
+
const liveAdds = survivor.adds.filter((x) => !merged.tombstones.some((t) => t.removedDots.some((d) => d.actor === x.dot.actor && d.counter === x.dot.counter)));
|
|
119
|
+
assert.strictEqual(liveAdds.length, 1);
|
|
120
|
+
assert.strictEqual(liveAdds[0]?.origin, "user", "the display origin must be the SURVIVING add's own, not a folded one");
|
|
121
|
+
});
|
|
122
|
+
run("M10: replaying the same dot is deduplicated by identity", async () => {
|
|
123
|
+
const merged = join(state([rule("Bash(ls)", [add("a", 1)])]), state([rule("Bash(ls)", [add("a", 1)])]));
|
|
124
|
+
assert.strictEqual(merged.rules[0]?.adds.length, 1);
|
|
125
|
+
});
|
|
126
|
+
run("M13: a tombstone whose (rule, scope) is locally absent is kept — arriving before its add is legal", async () => {
|
|
127
|
+
const merged = join(state(), state([], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]));
|
|
128
|
+
assert.strictEqual(merged.tombstones.length, 1);
|
|
129
|
+
const then = join(merged, state([rule("Bash(ls)", [add("a", 1), add("a", 4)])]));
|
|
130
|
+
assert.deepStrictEqual(liveDots(then), new Set(["Bash(ls)|a#4"]), "the pre-arrived tombstone must cover a#1 when it lands");
|
|
131
|
+
});
|
|
132
|
+
run("tombstone-union (r1-F3): same identity, differing removedDots ⇒ the union, both orders", async () => {
|
|
133
|
+
const a = state([], [tomb("Bash(ls)", [["a", 1]], ["a", 9])]);
|
|
134
|
+
const b = state([], [tomb("Bash(ls)", [["a", 2]], ["a", 9])]);
|
|
135
|
+
const ab = join(a, b);
|
|
136
|
+
assert.strictEqual(ab.tombstones.length, 1, "same (rule, scope, deletedBy) is ONE identity");
|
|
137
|
+
assert.deepStrictEqual(new Set(ab.tombstones[0]?.removedDots.map((d) => `${d.actor}#${d.counter}`)), new Set(["a#1", "a#2"]), "the union — a one-sided pick can drop an observed removal");
|
|
138
|
+
assert.deepStrictEqual(ab, join(b, a));
|
|
139
|
+
});
|
|
140
|
+
run("metadata re-projection (r2-F2): a record whose stored fields contradict its rule text is refused, both orders", async () => {
|
|
141
|
+
const forged = { rule: "Bash(ls)", tool: "Bash", match: "prefix", command: "rm -rf /", scope: GLOBAL, adds: [add("z", 1)] };
|
|
142
|
+
const clean = state([rule("Bash(git status)", [add("a", 1)])]);
|
|
143
|
+
const ab = join(state([forged]), clean);
|
|
144
|
+
const ba = join(clean, state([forged]));
|
|
145
|
+
assert.ok(!allDots(ab).has("Bash(ls)|z#1"), "the forged record must not survive the join");
|
|
146
|
+
assert.deepStrictEqual(ab, ba, "refusal must preserve commutativity");
|
|
147
|
+
assert.ok(allDots(ab).has("Bash(git status)|a#1"), "refusing one record must not disturb the rest");
|
|
148
|
+
});
|
|
149
|
+
run("single-validator door: an illegal rule shape (bare-interpreter prefix) cannot ride a join", async () => {
|
|
150
|
+
const merged = join(state([rule("Bash(node:*)", [add("z", 1)])]), state());
|
|
151
|
+
assert.strictEqual(allDots(merged).size, 0, "Bash(node:*) must be refused by the shared validator inside the join");
|
|
152
|
+
});
|
|
153
|
+
run("identity keys are collision-free: roots and actors containing spaces keep DISTINCT tombstone identities, and the covered add stays dead through an identity join", async () => {
|
|
154
|
+
const scopeA = { kind: "project", root: "/x" };
|
|
155
|
+
const scopeB = { kind: "project", root: "/x a" };
|
|
156
|
+
const s = state([rule("Bash(ls)", [add("v", 1)], scopeA), rule("Bash(ls)", [add("w", 1)], scopeB)], [
|
|
157
|
+
{ rule: "Bash(ls)", scope: scopeA, removedDots: [{ actor: "v", counter: 1 }], deletedBy: { actor: "a 1", counter: 2 } },
|
|
158
|
+
{ rule: "Bash(ls)", scope: scopeB, removedDots: [{ actor: "w", counter: 1 }], deletedBy: { actor: "1", counter: 2 } },
|
|
159
|
+
]);
|
|
160
|
+
const merged = join(s, state());
|
|
161
|
+
assert.strictEqual(merged.tombstones.length, 2, "two distinct delete intents must both survive — a colliding key folds one away");
|
|
162
|
+
assert.strictEqual(liveDots(merged).size, 0, "both covered adds must stay dead");
|
|
163
|
+
assert.deepStrictEqual(join(merged, merged), merged, "idempotence must hold with space-carrying roots/actors");
|
|
164
|
+
});
|
|
165
|
+
run("state vector: max over add dots ∪ deletedBy ∪ removedDots (§5.1)", async () => {
|
|
166
|
+
const v = vector(state([rule("Bash(ls)", [add("a", 3)])], [tomb("Bash(git status)", [["a", 7], ["b", 2]], ["c", 4])]));
|
|
167
|
+
assert.deepStrictEqual(v, { a: 7, b: 2, c: 4 });
|
|
168
|
+
});
|
|
169
|
+
run("paired collection: a fully-below tombstone leaves WITH its covered adds; a partial one stays (§5.1)", async () => {
|
|
170
|
+
const s = state([rule("Bash(ls)", [add("a", 1)]), rule("Bash(pwd)", [add("a", 2), add("b", 9)])], [tomb("Bash(ls)", [["a", 1]], ["a", 3]), tomb("Bash(pwd)", [["a", 2], ["b", 9]], ["a", 4])]);
|
|
171
|
+
const { state: out, collectedTombstones } = collect(s, { a: 5 });
|
|
172
|
+
assert.strictEqual(collectedTombstones, 1, "only the fully-below pair is collectable (b#9 is above the frontier)");
|
|
173
|
+
assert.ok(!allDots(out).has("Bash(ls)|a#1"), "the covered add leaves WITH its tombstone — never one without the other");
|
|
174
|
+
assert.ok(allDots(out).has("Bash(pwd)|b#9"), "an above-frontier dot must never be collected");
|
|
175
|
+
assert.strictEqual(out.tombstones.length, 1, "the partially-below tombstone stays");
|
|
176
|
+
assert.deepStrictEqual(liveDots(out), liveDots(s));
|
|
177
|
+
});
|
|
178
|
+
run("collection + rejoin does not resurrect: the recycled pair stays gone through further joins", async () => {
|
|
179
|
+
const s = state([rule("Bash(ls)", [add("a", 1)])], [tomb("Bash(ls)", [["a", 1]], ["a", 2])]);
|
|
180
|
+
const { state: collected } = collect(s, { a: 2 });
|
|
181
|
+
const rejoined = join(collected, collected);
|
|
182
|
+
assert.strictEqual(liveDots(rejoined).size, 0);
|
|
183
|
+
assert.strictEqual(allDots(rejoined).size, 0, "neither the add nor the tombstone may reappear from a self-join");
|
|
184
|
+
});
|
|
185
|
+
await settle();
|
|
186
|
+
}
|
|
@@ -82,8 +82,11 @@ export interface TaskNotificationPayload {
|
|
|
82
82
|
recentSteps?: import("../agents/subagent-steps.js").SubagentStep[];
|
|
83
83
|
/** Residual lane D (core half) — files the child mutated with edit counts. Background-agent lane only. */
|
|
84
84
|
editedFiles?: import("../agents/subagent-steps.js").SubagentEditedFile[];
|
|
85
|
-
/** Residual lane E — `true` when the child can be revived via SendMessage
|
|
86
|
-
*
|
|
85
|
+
/** Residual lane E — `true` when the child can be revived via SendMessage: a live retained session that
|
|
86
|
+
* was not killed, OR a NAMED durable row that no USER stop closed (a parent teardown / reap / host
|
|
87
|
+
* death is what the durable revival lane recovers from). Judged by the revival ladder's own
|
|
88
|
+
* preconditions, so the claim and the next SendMessage agree. Lets the parent decide
|
|
89
|
+
* continue-vs-restart without trial-and-error. Background-agent lane only. */
|
|
87
90
|
resumable?: boolean;
|
|
88
91
|
/** P1-3(黑板 [1920]/[1921]/[1924]/[1925], cli/server 商定项) — a cross-channel correlation key for
|
|
89
92
|
* ONE completion event. The same task's completion fans out across several independent read faces
|
|
@@ -311,7 +311,7 @@ export declare function reviveBackgroundAgentLane(core: DurableAgentCore, id: st
|
|
|
311
311
|
cycle: number;
|
|
312
312
|
} | {
|
|
313
313
|
ok: false;
|
|
314
|
-
reason: "not_found" | "still_running";
|
|
314
|
+
reason: "not_found" | "still_running" | "recycling";
|
|
315
315
|
}>;
|
|
316
316
|
/** S2b RB-27② — settle a REVIVED cycle (cycle-stamped: a stale cycle's late settle is a no-op so
|
|
317
317
|
* it can never flip a newer revived cycle back to terminal / clear its channel). */
|
|
@@ -216,6 +216,7 @@ export async function reapDurableAgentsLane(core, scope, deps, policy) {
|
|
|
216
216
|
continue;
|
|
217
217
|
}
|
|
218
218
|
rowsReaped++;
|
|
219
|
+
core.reapedHandles.add(r.handle);
|
|
219
220
|
if (deps.mailbox !== undefined) {
|
|
220
221
|
try {
|
|
221
222
|
await deps.mailbox.drop(scope, r.handle);
|
|
@@ -235,6 +236,7 @@ export async function reapDurableAgentsLane(core, scope, deps, policy) {
|
|
|
235
236
|
}
|
|
236
237
|
finally {
|
|
237
238
|
core.reapingHandles.delete(r.handle);
|
|
239
|
+
core.reapedHandles.delete(r.handle);
|
|
238
240
|
}
|
|
239
241
|
}
|
|
240
242
|
return { rowsReaped, sessionsReleased, skippedNoSessions };
|
|
@@ -919,11 +921,13 @@ async function rollbackRevivalClaim(store, claim) {
|
|
|
919
921
|
}
|
|
920
922
|
}
|
|
921
923
|
export async function reviveBackgroundAgentLane(core, id, access, abort) {
|
|
922
|
-
if (core.reapingHandles.has(id) || core.claimingHandles.has(id))
|
|
923
|
-
return { ok: false, reason: "not_found" };
|
|
924
924
|
const handle = core.handles.get(id);
|
|
925
925
|
if (!handle || handle.type !== "background_agent" || !canAccess(handle, access))
|
|
926
926
|
return { ok: false, reason: "not_found" };
|
|
927
|
+
if (core.reapedHandles.has(id))
|
|
928
|
+
return { ok: false, reason: "not_found" };
|
|
929
|
+
if (core.reapingHandles.has(id) || core.claimingHandles.has(id))
|
|
930
|
+
return { ok: false, reason: "recycling" };
|
|
927
931
|
if (handle.status === "running" || handle.status === "pending")
|
|
928
932
|
return { ok: false, reason: "still_running" };
|
|
929
933
|
if (handle.status === "parked")
|
|
@@ -773,8 +773,9 @@ export declare function closestName(query: string, candidates: Iterable<string>)
|
|
|
773
773
|
* missing axis here is a broken invariant, answered with denial, never a pass. */
|
|
774
774
|
export declare function canAccess(handle: SemaTaskHandle, access: TaskAccess): boolean;
|
|
775
775
|
/** design/157 B13 二期(B13 档预调研 §4.2/§4.3 定稿)— the durable-agent lane's widened core seam.
|
|
776
|
-
* Extends {@link RegistryCore} with
|
|
777
|
-
*
|
|
776
|
+
* Extends {@link RegistryCore} with the extra members the AGENT lane's `this.*` census reached
|
|
777
|
+
* (73 处/9 成员,扣车道内互调后落在 5 个上,见 B13 档「二期细察补充」;`reapedHandles` 是后加的
|
|
778
|
+
* 第 6 个——reap 窗口的后半段,见其自身注释)。
|
|
778
779
|
* `durableAgentWrite`/`ensureDurableHeartbeat`/`settleBackgroundAgent` are NOT here — they are lane
|
|
779
780
|
* members themselves (车道内直接函数调用;类上只留转发或不留)。The class satisfies this via the
|
|
780
781
|
* same cached-view field, upgraded with lazy getter/setter proxies (an IIFE capturing `this`) so
|
|
@@ -783,6 +784,12 @@ export declare function canAccess(handle: SemaTaskHandle, access: TaskAccess): b
|
|
|
783
784
|
export interface DurableAgentCore extends RegistryCore {
|
|
784
785
|
readonly writerId: string;
|
|
785
786
|
readonly reapingHandles: Set<string>;
|
|
787
|
+
/** The SECOND half of the reap window: ids whose durable row the sweep has already DELETED under its
|
|
788
|
+
* rev guard, still inside the lifecycle fence while the sweep drops the mailbox and releases the
|
|
789
|
+
* session. The fence must hold across that cleanup, but the row is gone from the moment the delete
|
|
790
|
+
* wins, and the two halves owe a caller opposite answers — "this clears on its own, send again"
|
|
791
|
+
* versus "it is gone, relaunch". A subset of `reapingHandles`, cleared with it. */
|
|
792
|
+
readonly reapedHandles: Set<string>;
|
|
786
793
|
readonly claimingHandles: Set<string>;
|
|
787
794
|
/** get/set proxy — `ensureDurableHeartbeatLane` both reads AND writes the timer slot. */
|
|
788
795
|
durableHeartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -273,7 +273,7 @@ export declare class TaskRegistry {
|
|
|
273
273
|
cycle: number;
|
|
274
274
|
} | {
|
|
275
275
|
ok: false;
|
|
276
|
-
reason: "not_found" | "still_running";
|
|
276
|
+
reason: "not_found" | "still_running" | "recycling";
|
|
277
277
|
}>;
|
|
278
278
|
settleRevivedAgent(id: string, cycle: number, outcome: {
|
|
279
279
|
status: "completed" | "failed" | "killed";
|
|
@@ -324,15 +324,21 @@ export declare class TaskRegistry {
|
|
|
324
324
|
* the session is released under an ADMITTED resume, and the revive's queued write poisons on
|
|
325
325
|
* "gone"). Membership is synchronous on both sides, with three readers (the `claimingHandles` twin
|
|
326
326
|
* below carries the same fence set): reap marks BEFORE its terminal check, a revival lane refuses
|
|
327
|
-
* while marked (the row is being recycled
|
|
327
|
+
* while marked (the row is being recycled), and the terminal-handle
|
|
328
328
|
* GC skips a marked id instead of evicting it. */
|
|
329
329
|
private reapingHandles;
|
|
330
|
+
/** The post-delete half of the window above (see {@link DurableAgentCore.reapedHandles}): the sweep's
|
|
331
|
+
* guarded delete has WON, and the fence is still held while the mailbox is dropped and the session
|
|
332
|
+
* released. The fence's readers are unchanged — this only splits what a refused revival is TOLD,
|
|
333
|
+
* since a caller advised to retry a row that no longer exists would retry forever. */
|
|
334
|
+
private reapedHandles;
|
|
330
335
|
/** design/151 §7.3 (F-8) — handles a revival lane is CURRENTLY claiming (between the claim-CAS
|
|
331
336
|
* decision and the revive registration/rollback). BOTH lanes are writers: the tier-3 SendMessage
|
|
332
337
|
* claim, and (since the retained revive started claiming its own row) the in-process retained-revive
|
|
333
338
|
* path, which holds the mark across its store round trip. Synchronous membership on both sides, the
|
|
334
339
|
* `reapingHandles` twin, with three readers: the reap sweep skips a mid-claim row (its rev is about
|
|
335
|
-
* to move), the sibling revival lane refuses one (`reviveBackgroundAgent`
|
|
340
|
+
* to move), the sibling revival lane refuses one (`reviveBackgroundAgent` recycling — a transient
|
|
341
|
+
* hold, told apart from a row that is actually gone), and the
|
|
336
342
|
* terminal-handle GC skips it — so the revival lanes, the reaper and the GC can never adjudicate the
|
|
337
343
|
* same handle concurrently in-process. */
|
|
338
344
|
private claimingHandles;
|
|
@@ -116,6 +116,7 @@ export class TaskRegistry {
|
|
|
116
116
|
pokeBgQuiescence: (owner) => self.pokeBgQuiescence(owner),
|
|
117
117
|
get writerId() { return self.writerId; },
|
|
118
118
|
get reapingHandles() { return self.reapingHandles; },
|
|
119
|
+
get reapedHandles() { return self.reapedHandles; },
|
|
119
120
|
get claimingHandles() { return self.claimingHandles; },
|
|
120
121
|
get durableHeartbeatTimer() { return self.durableHeartbeatTimer; },
|
|
121
122
|
set durableHeartbeatTimer(v) { self.durableHeartbeatTimer = v; },
|
|
@@ -233,6 +234,7 @@ export class TaskRegistry {
|
|
|
233
234
|
}
|
|
234
235
|
writerId = randomBytes(8).toString("hex");
|
|
235
236
|
reapingHandles = new Set();
|
|
237
|
+
reapedHandles = new Set();
|
|
236
238
|
claimingHandles = new Set();
|
|
237
239
|
durableHeartbeatTimer;
|
|
238
240
|
mintTaskId(type) {
|
|
@@ -44,6 +44,12 @@ export interface ToolCallRequest {
|
|
|
44
44
|
* when a policy is invoked directly (outside a Runner). A NORMAL-ask escalation policy reads it; a SAFETY
|
|
45
45
|
* ask (egress / irreversible) ignores it and ALWAYS asks (invariant #2 stays structural — core mints
|
|
46
46
|
* `irreversible_ask` for safety tools regardless of any budget).
|
|
47
|
+
*
|
|
48
|
+
* ⚖️ SCOPE (A-005.13, ruled 2026-08-09): this snapshot is PER-RUN — it is the ledger of the run whose
|
|
49
|
+
* gate is asking, NEVER the ledger of the run that authored the policy. A policy inherited down the
|
|
50
|
+
* delegation chain (`InheritedGate.parentConstraints`) therefore self-limits against each CHILD's own
|
|
51
|
+
* fresh ledger; the ancestor's counts do not travel (declared design — see the chain field's A-005.13
|
|
52
|
+
* note for the reasoning and the closure-state alternative).
|
|
47
53
|
*/
|
|
48
54
|
budget?: {
|
|
49
55
|
/**
|
|
@@ -60,8 +66,11 @@ export interface ToolCallRequest {
|
|
|
60
66
|
suspendCount: number;
|
|
61
67
|
};
|
|
62
68
|
}
|
|
63
|
-
/** Where a decision came from, for audit (design/37).
|
|
64
|
-
|
|
69
|
+
/** Where a decision came from, for audit (design/37). `"sandbox"` (F-012 L2) marks an allow the
|
|
70
|
+
* sandbox-admission leg resolved: the deployment declared an isolated execution env, every surviving
|
|
71
|
+
* ask on the call was engine-classified sandbox-local, and the call crosses no declared boundary —
|
|
72
|
+
* disclosed durably as `permission.sandbox_admitted`. */
|
|
73
|
+
export type DecisionReason = "rule" | "mode" | "hook" | "safety" | "classifier" | "persisted_rule" | "sandbox";
|
|
65
74
|
/**
|
|
66
75
|
* WHO (or what) ENDED an approval — the machine-readable twin of a settlement's human-readable text,
|
|
67
76
|
* so a consumer tells "a person decided this" from "nobody answered" without prose-matching a sentence.
|
|
@@ -164,7 +173,116 @@ export declare function decisionText(d: PermissionResult): string | undefined;
|
|
|
164
173
|
*/
|
|
165
174
|
export interface ToolPolicy {
|
|
166
175
|
check(req: ToolCallRequest, signal?: AbortSignal): PermissionResult | Promise<PermissionResult>;
|
|
176
|
+
/**
|
|
177
|
+
* The policy's CONSTRAINT PROJECTION — its deterministic DENY-verdict half expressed as pure data
|
|
178
|
+
* (F-012 L1), so a delegation chain / durable checkpoint can freeze what this policy refuses without
|
|
179
|
+
* carrying the live closure. Optional: a policy without one is OPAQUE to the projection plane (its
|
|
180
|
+
* live closure keeps the resume re-supply contract it has today). Built-in factories in this module
|
|
181
|
+
* attach one where the deny half IS data (name allow/deny lists, shell command-name lists);
|
|
182
|
+
* a custom closure policy simply opts out.
|
|
183
|
+
*
|
|
184
|
+
* The projection is a SUBSET contract, never a replacement: executing it yields every deny the
|
|
185
|
+
* data half implies, and says nothing about the policy's live remainder (asks, approvals,
|
|
186
|
+
* rewrites). `requiresLiveRemainder` states explicitly whether such a remainder exists — "has a
|
|
187
|
+
* projection" ≠ "fully projectable", and consumers deciding whether a frozen projection COMPLETELY
|
|
188
|
+
* describes the policy must read that bit, not the field's presence.
|
|
189
|
+
*/
|
|
190
|
+
readonly projection?: ToolPolicyProjection;
|
|
167
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* One deterministic DENY component of a {@link ToolPolicyProjection} (F-012 L1). All fields are plain
|
|
194
|
+
* serializable data — a component round-trips JSON/`structuredClone` unchanged, which is what lets a
|
|
195
|
+
* chain of them persist on a checkpoint and be re-executed verbatim on resume.
|
|
196
|
+
*
|
|
197
|
+
* - `tool_deny`: deny any call whose toolName is in `names`.
|
|
198
|
+
* - `tool_allowlist`: deny any call whose toolName is NOT in `names`.
|
|
199
|
+
* - `shell_command`: for calls to the named shell `tools`, parse the leading command name and deny
|
|
200
|
+
* per `deny`/`allow` lists; `unmatchedAction`/`unparseableAction` say whether the projection may
|
|
201
|
+
* speak for those arms (`"deny"`) or must stay silent (`"none"` — the live policy's ask arm owns
|
|
202
|
+
* them). Non-shell tools are out of scope (no opinion).
|
|
203
|
+
*/
|
|
204
|
+
export type ToolPolicyProjectionComponent = {
|
|
205
|
+
kind: "tool_deny";
|
|
206
|
+
names: readonly string[];
|
|
207
|
+
} | {
|
|
208
|
+
kind: "tool_allowlist";
|
|
209
|
+
names: readonly string[];
|
|
210
|
+
} | {
|
|
211
|
+
kind: "shell_command";
|
|
212
|
+
tools: readonly string[];
|
|
213
|
+
deny: readonly string[];
|
|
214
|
+
allow?: readonly string[];
|
|
215
|
+
/** Verdict for a parsed command name outside `allow` (only meaningful when `allow` is present). */
|
|
216
|
+
unmatchedAction: "deny" | "none";
|
|
217
|
+
/** Verdict for a command string this projection cannot parse (compound/missing command). */
|
|
218
|
+
unparseableAction: "deny" | "none";
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* The deterministic data half of a {@link ToolPolicy} (F-012 L1): ordered deny components plus the
|
|
222
|
+
* explicit live-remainder bit. See {@link ToolPolicy.projection} for the contract.
|
|
223
|
+
*/
|
|
224
|
+
export interface ToolPolicyProjection {
|
|
225
|
+
readonly components: readonly ToolPolicyProjectionComponent[];
|
|
226
|
+
/**
|
|
227
|
+
* `true` when the policy has behavior BEYOND these components (an ask arm, an approval callback,
|
|
228
|
+
* a rewrite) — the projection then freezes only the deny half and the live policy must still ride
|
|
229
|
+
* the resume re-supply channel for the rest. `false` = the components plus "allow otherwise" ARE
|
|
230
|
+
* the whole policy (fully projectable — e.g. a plain name allow/deny policy).
|
|
231
|
+
*/
|
|
232
|
+
readonly requiresLiveRemainder: boolean;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Execute a persisted {@link ToolPolicyProjection} against a call (F-012 L1): returns the first
|
|
236
|
+
* component's deny, or `undefined` when the projection has no opinion (it is deny-only by
|
|
237
|
+
* construction — never an allow, never an ask). This is what a durable RESUME runs instead of
|
|
238
|
+
* trusting a re-supplied policy's execution half for the frozen deny data: the projection persisted
|
|
239
|
+
* at suspend is the authority for what the chain refused, whatever a re-supplied closure claims.
|
|
240
|
+
*/
|
|
241
|
+
export declare function checkToolPolicyProjection(projection: Pick<ToolPolicyProjection, "components">, req: {
|
|
242
|
+
toolName: string;
|
|
243
|
+
args: unknown;
|
|
244
|
+
}): Extract<PermissionResult, {
|
|
245
|
+
action: "deny";
|
|
246
|
+
}> | undefined;
|
|
247
|
+
/**
|
|
248
|
+
* One entry of the persisted constraint-chain topology (F-012 L1): either a layer's projection
|
|
249
|
+
* content, or an explicit OPAQUE placeholder for a layer that exports none. The placeholder is
|
|
250
|
+
* load-bearing — the chain digest binds the ORDERED topology (layer boundaries included), so an
|
|
251
|
+
* opaque layer's position is part of what a resume re-supply must reproduce.
|
|
252
|
+
*
|
|
253
|
+
* F-012 (codex round 12): each entry ALSO carries the serializable DECISION-CHAIN METADATA posture —
|
|
254
|
+
* whether the layer was classifier-armed (`autoModeArmed`) and its durable-mandate / content-mandate
|
|
255
|
+
* flags. The digest binds these, so a cross-process resume re-supplying a same-shape chain whose
|
|
256
|
+
* projections match but whose classifier was DROPPED (or whose mandate posture changed) — a
|
|
257
|
+
* version-skew / reconfiguration widening — is rejected pre-CAS. The metadata is NOT read by
|
|
258
|
+
* {@link checkToolPolicyProjection} (execution reads only `components`); it exists purely to be
|
|
259
|
+
* digested. The frozen closures themselves (approver, decider) are non-serializable and stay outside
|
|
260
|
+
* the digest — the re-supply trust contract governs their identity — but their PRESENCE/posture is
|
|
261
|
+
* bound so a re-supply cannot silently shed a decision-chain layer.
|
|
262
|
+
*/
|
|
263
|
+
export type ConstraintChainEntryMeta = {
|
|
264
|
+
/** True iff the layer carried a frozen auto-mode classifier (`autoMode`). */
|
|
265
|
+
autoModeArmed?: true;
|
|
266
|
+
durableMandate?: true;
|
|
267
|
+
contentMandate?: true;
|
|
268
|
+
};
|
|
269
|
+
export type ConstraintChainEntry = ConstraintChainEntryMeta & ({
|
|
270
|
+
opaque: true;
|
|
271
|
+
} | {
|
|
272
|
+
components: readonly ToolPolicyProjectionComponent[];
|
|
273
|
+
requiresLiveRemainder: boolean;
|
|
274
|
+
});
|
|
275
|
+
/** Project one policy into its chain entry (opaque placeholder when it exports no projection),
|
|
276
|
+
* carrying the layer's decision-chain metadata posture for the digest (round 12). */
|
|
277
|
+
export declare function constraintChainEntryOf(policy: ToolPolicy, meta?: ConstraintChainEntryMeta): ConstraintChainEntry;
|
|
278
|
+
/**
|
|
279
|
+
* Content digest over an ordered constraint chain (F-012 L1) — the durable half's anti-tamper anchor.
|
|
280
|
+
* Binds the chain TOPOLOGY (order, layer boundaries, opaque placeholders) and each projection's full
|
|
281
|
+
* content; version-prefixed (`cpv1:`) so a digest computed under different projection semantics can
|
|
282
|
+
* never false-match. Replaces the count-only re-supply contract: two chains of equal length with
|
|
283
|
+
* different frozen deny data now digest differently.
|
|
284
|
+
*/
|
|
285
|
+
export declare function constraintChainDigest(chain: readonly ConstraintChainEntry[]): string;
|
|
168
286
|
/**
|
|
169
287
|
* Refuse a decision that speaks the retired `reason` dialect (RB-479-B①, tripwire ruled 2026-08-03).
|
|
170
288
|
*
|