@sema-agent/core 7.1.0 → 7.3.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 +65 -0
- package/dist/agents/cross-session-envelope.d.ts +145 -0
- package/dist/agents/cross-session-envelope.js +195 -0
- package/dist/agents/cross-session-judge.d.ts +119 -0
- package/dist/agents/cross-session-judge.js +184 -0
- package/dist/agents/cross-session-ref.d.ts +52 -0
- package/dist/agents/cross-session-ref.js +64 -0
- package/dist/agents/list-agents-tool.d.ts +55 -0
- package/dist/agents/list-agents-tool.js +94 -0
- package/dist/agents/peer-admission.d.ts +17 -1
- package/dist/agents/peer-admission.js +19 -2
- package/dist/agents/peer-directory.d.ts +208 -0
- package/dist/agents/peer-directory.js +272 -0
- package/dist/agents/peer-session-drain.d.ts +159 -0
- package/dist/agents/peer-session-drain.js +245 -0
- package/dist/agents/send-message-tool.d.ts +44 -0
- package/dist/agents/send-message-tool.js +181 -16
- package/dist/agents/subagent-steps.d.ts +11 -0
- package/dist/agents/subagent-steps.js +27 -4
- package/dist/core/auto-mode-arming.d.ts +11 -0
- package/dist/core/auto-mode-arming.js +7 -1
- package/dist/core/auto-mode-prompt.d.ts +5 -0
- package/dist/core/auto-mode-prompt.js +2 -1
- package/dist/core/auto-mode-rebuild.d.ts +2 -1
- package/dist/core/auto-mode-rebuild.js +2 -0
- package/dist/core/checkpoint-store.d.ts +203 -3
- package/dist/core/checkpoint-store.js +60 -19
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +6 -0
- package/dist/core/hooks.d.ts +15 -8
- package/dist/core/hooks.js +6 -3
- package/dist/core/mailbox-store.d.ts +89 -2
- package/dist/core/mailbox-store.js +77 -2
- package/dist/core/permission-rule-consent.d.ts +72 -23
- package/dist/core/permission-rule-consent.js +115 -26
- package/dist/core/permission-rule-model.d.ts +254 -51
- package/dist/core/permission-rule-model.js +316 -55
- package/dist/core/permission-rule-org.js +13 -6
- package/dist/core/remote-env.d.ts +8 -1
- package/dist/core/runner/assemble-result.js +2 -1
- package/dist/core/runner/prepare-task.d.ts +59 -1
- package/dist/core/runner/prepare-task.js +414 -149
- package/dist/core/runner/prepare-workspace-restore.d.ts +6 -1
- package/dist/core/runner/prepare-workspace-restore.js +2 -1
- package/dist/core/runner/runtask.js +16 -5
- package/dist/core/runner/tool-output-projection.js +1 -0
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +23 -0
- package/dist/core/store-contracts/mailbox-store-contract.js +157 -1
- package/dist/core/task-notification.d.ts +93 -5
- package/dist/core/task-notification.js +31 -4
- package/dist/core/tool-policy.d.ts +11 -0
- package/dist/core/types.d.ts +155 -21
- package/dist/core/untrusted-text.js +17 -1
- package/dist/core/wiring-manifest.d.ts +21 -0
- package/dist/core/wiring-manifest.js +1 -0
- package/dist/index.d.ts +14 -5
- package/dist/index.js +13 -4
- package/dist/stores/cc/mailbox-store.d.ts +1 -1
- package/dist/stores/cc/mailbox-store.js +13 -0
- package/dist/stores/file/adoption/marker.d.ts +1 -1
- package/dist/stores/file/mailbox-store.d.ts +57 -0
- package/dist/stores/file/mailbox-store.js +369 -18
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +233 -1
|
@@ -32,7 +32,12 @@ export declare function remoteEnvFailureNote(op: RemoteEnvFailureNote["op"], err
|
|
|
32
32
|
* decides. A code outside the retryable family (`unsupported`, `auth_failed`) returns on the first
|
|
33
33
|
* attempt — `withRetry` will not spend a second call on a permanent refusal.
|
|
34
34
|
*/
|
|
35
|
-
export declare function restoreWorkspaceWithRetry(env: RemoteExecutionEnv, snapshotId: SnapshotId, options: VmLifecycleOptions
|
|
35
|
+
export declare function restoreWorkspaceWithRetry(env: RemoteExecutionEnv, snapshotId: SnapshotId, options: VmLifecycleOptions,
|
|
36
|
+
/** design/384 slice 2 (additive): observe each attempt AS IT STARTS. The returned `attempts` is
|
|
37
|
+
* only readable after settlement, and a caller whose wait on this promise is BOUNDED (the park
|
|
38
|
+
* compensation) must report the live attempt count when its bound fires mid-call — without this
|
|
39
|
+
* seat a deaf second attempt was reported as `attempts: 1`. */
|
|
40
|
+
onAttempt?: (attempt: number) => void): Promise<{
|
|
36
41
|
outcome: Awaited<ReturnType<RemoteExecutionEnv["resumeVM"]>>;
|
|
37
42
|
attempts: number;
|
|
38
43
|
}>;
|
|
@@ -19,10 +19,11 @@ export function remoteEnvFailureNote(op, error, attempts) {
|
|
|
19
19
|
}
|
|
20
20
|
const REMOTE_RESTORE_MAX_ATTEMPTS = 2;
|
|
21
21
|
const REMOTE_RESTORE_BACKOFF_MS = 200;
|
|
22
|
-
export async function restoreWorkspaceWithRetry(env, snapshotId, options) {
|
|
22
|
+
export async function restoreWorkspaceWithRetry(env, snapshotId, options, onAttempt) {
|
|
23
23
|
let attempts = 0;
|
|
24
24
|
const outcome = await withRetry(async (attempt) => {
|
|
25
25
|
attempts = attempt;
|
|
26
|
+
onAttempt?.(attempt);
|
|
26
27
|
return env.resumeVM(snapshotId, options);
|
|
27
28
|
}, { retryableCodes: RETRYABLE_REMOTE_ERROR_CODES, maxAttempts: REMOTE_RESTORE_MAX_ATTEMPTS, backoffMs: () => REMOTE_RESTORE_BACKOFF_MS }, { ...(options.abortSignal !== undefined ? { signal: options.abortSignal } : {}) });
|
|
28
29
|
return { outcome, attempts };
|
|
@@ -21,6 +21,7 @@ import { ORG_ADJUDICATION_TIMEOUT_MS, settleOrgVerdictWithin } from "../permissi
|
|
|
21
21
|
import { emitTaskOutcome } from "../task-outcome.js";
|
|
22
22
|
import { isDegenerateCutMessage } from "../../brain/terminal-cause.js";
|
|
23
23
|
import { primaryActivityArg } from "../arg-summary.js";
|
|
24
|
+
import { redactThenCut } from "../../agents/subagent-steps.js";
|
|
24
25
|
import { resolveReasoning } from "../../brain/reasoning.js";
|
|
25
26
|
import { adjudicateDerivedRoute, authCarrierFingerprint, fallbackToPrimaryNotice, normalizeBaseUrl, sameRouteIdentity } from "../../brain/route-adjudicator.js";
|
|
26
27
|
import { readDegradation } from "../../brain/degrading.js";
|
|
@@ -773,7 +774,7 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
|
|
|
773
774
|
boundaryAttachmentBytes += Buffer.byteLength(a.body, "utf8");
|
|
774
775
|
rs.attach.attachmentsInjected += due.length;
|
|
775
776
|
for (const a of due) {
|
|
776
|
-
queue.push({ type: "steering_injected", source: a.source, preview: a.body
|
|
777
|
+
queue.push({ type: "steering_injected", source: a.source, preview: redactThenCut(a.body, 220), ...ident() });
|
|
777
778
|
}
|
|
778
779
|
}
|
|
779
780
|
}
|
|
@@ -3821,7 +3822,7 @@ export class Runner {
|
|
|
3821
3822
|
}
|
|
3822
3823
|
for (const f of firstFrames) {
|
|
3823
3824
|
f.commit();
|
|
3824
|
-
queue.push({ type: "steering_injected", source: f.source, preview: f.body
|
|
3825
|
+
queue.push({ type: "steering_injected", source: f.source, preview: redactThenCut(f.body, 220), ...ident() });
|
|
3825
3826
|
}
|
|
3826
3827
|
rs.attach.attachmentsInjected += firstFrames.length;
|
|
3827
3828
|
if (firstFrames.length > 0)
|
|
@@ -5080,12 +5081,22 @@ export class Runner {
|
|
|
5080
5081
|
await recheckGovernanceWindow();
|
|
5081
5082
|
}
|
|
5082
5083
|
this.locallyClaimedTokens.add(token);
|
|
5083
|
-
|
|
5084
|
+
let won;
|
|
5085
|
+
let claimLossStatus;
|
|
5086
|
+
if (store.claimTerminal !== undefined) {
|
|
5087
|
+
const claim = await store.claimTerminal(token, cp.scope, { kind: "resolve", outcome: outcomeForStore, expect: { rev: cp.rev ?? 0 } });
|
|
5088
|
+
won = claim.claimed;
|
|
5089
|
+
if (!claim.claimed)
|
|
5090
|
+
claimLossStatus = claim.current.status;
|
|
5091
|
+
}
|
|
5092
|
+
else {
|
|
5093
|
+
won = await store.resolve(token, cp.scope, outcomeForStore, { rev: cp.rev ?? 0 });
|
|
5094
|
+
}
|
|
5084
5095
|
if (!won)
|
|
5085
5096
|
this.locallyClaimedTokens.delete(token);
|
|
5086
5097
|
if (!won) {
|
|
5087
|
-
const
|
|
5088
|
-
if (
|
|
5098
|
+
const stillPending = claimLossStatus !== undefined ? claimLossStatus === "pending" : (await store.get(token))?.status === "pending";
|
|
5099
|
+
if (stillPending) {
|
|
5089
5100
|
throw new CheckpointError("checkpoint.reopened_concurrently", "checkpoint changed concurrently (its revision advanced via a resolve/reopen cycle since this resume validated) — not executed; re-resume against the current state");
|
|
5090
5101
|
}
|
|
5091
5102
|
this.parentConstraintRegistry.delete(token);
|
|
@@ -64,6 +64,7 @@ const CC_DETAIL_TYPES = new Set([
|
|
|
64
64
|
"task-stop", "tool-search", "repo-map", "fork", "enter-plan-mode", "exit-plan-mode",
|
|
65
65
|
"monitor-start", "path_not_in_root", "read_path_denied", "readonly_out_of_root", "bash_invalid_timeout",
|
|
66
66
|
"report-findings", "schedule-wakeup", "send-message", "agent-transcript", "a2a", "document",
|
|
67
|
+
"list-agents",
|
|
67
68
|
]);
|
|
68
69
|
export const structuredFrom = (result) => {
|
|
69
70
|
const details = result !== null && typeof result === "object" ? result.details : undefined;
|
|
@@ -46,6 +46,29 @@ export declare function mailboxAckOwnershipContract(mk: () => MailboxStore, runA
|
|
|
46
46
|
* 放宽」:核心层没有一条为了任何后端而弱化。第三方后端若有租户轴,应当也挂这一层。
|
|
47
47
|
*/
|
|
48
48
|
export declare function mailboxBundledOnlyContract(mk: () => MailboxStore, runAssertion?: ContractAssertionRunner): Promise<void>;
|
|
49
|
+
export interface MailboxCrossProcessContractHooks {
|
|
50
|
+
/** Open ONE fresh, empty storage location TWICE: `local` in this process and `peer` served by ANOTHER
|
|
51
|
+
* OS process over the same storage (the peer is a real second process — an in-process second
|
|
52
|
+
* instance proves nothing here). `dispose` closes both and may remove the storage. The kit calls it
|
|
53
|
+
* once per case. */
|
|
54
|
+
openPair: () => Promise<{
|
|
55
|
+
local: MailboxStore;
|
|
56
|
+
peer: MailboxStore;
|
|
57
|
+
dispose: () => Promise<void>;
|
|
58
|
+
}>;
|
|
59
|
+
runAssertion?: ContractAssertionRunner;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* T2 可选能力层(cross-process 分册,design/385 §1.2⑥ / G11)—— 声明 `crossProcessSafe: true` 的后端必须
|
|
63
|
+
* 过的全套判据。钉的是**整套**(跨进程 seq 单铸 + lease/ack 不交叉 + 一方的 housekeeping 不丢另一方的
|
|
64
|
+
* 已落盘 append + reap/drop 的 seq 语义跨进程成立),不是「有锁」:一把锁配一份陈旧缓存照样在锁内铸
|
|
65
|
+
* 重号、照样把别人的消息压缩掉——所以每一条都从**对方进程的视角**回读。
|
|
66
|
+
*
|
|
67
|
+
* 受众:文件后端(本仓捆绑)、SQL 后端(事务天然过)。进程内 Map 的参照实现与 CC inbox 适配器不声明,
|
|
68
|
+
* 也不挂——它们不是「没做到所以放宽」,而是根本不在跨进程车道上(挂载判据 `mailboxCrossProcessMountVerdict`
|
|
69
|
+
* 对未声明的后端响亮拒)。
|
|
70
|
+
*/
|
|
71
|
+
export declare function mailboxCrossProcessContract(hooks: MailboxCrossProcessContractHooks): Promise<void>;
|
|
49
72
|
export interface MailboxTombstonedRecipientContractHooks {
|
|
50
73
|
/** Put `(scope, handle)` into the deployment's PRE-DELETE state — whatever that is for this
|
|
51
74
|
* backend (a cascade marking the session row, a `deleting` column, a tombstone table). Core does
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { strict as assert } from "node:assert";
|
|
2
|
-
import { MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../mailbox-store.js";
|
|
2
|
+
import { MAILBOX_INVALID_PEER_META_CODE, MAILBOX_TOMBSTONED_RECIPIENT_CODE } from "../mailbox-store.js";
|
|
3
3
|
import { beginContract } from "./contract-harness.js";
|
|
4
4
|
export const MAILBOX_CONTRACT_SCOPE = "default";
|
|
5
5
|
const msg = (content, sentAt = 1000) => ({ content, sentAt });
|
|
@@ -112,6 +112,75 @@ export async function mailboxStoreContract(mk, runAssertion) {
|
|
|
112
112
|
const after = await s.claimLease(S, "a1", "rival", 60_000, 12_000);
|
|
113
113
|
assert.deepEqual(after?.messages.map((m) => m.seq), [1], "归还后同一条按原 seq 回可见");
|
|
114
114
|
});
|
|
115
|
+
run("peerMeta 回环:典型 typed 记录逐字节回读;缺席保持缺席(pre-385/foreign 记录语义);空对象也是「在场」", async (make) => {
|
|
116
|
+
const s = make();
|
|
117
|
+
const full = {
|
|
118
|
+
fromSession: "sess_1",
|
|
119
|
+
fromMode: "prompting",
|
|
120
|
+
senderKey: "k-1",
|
|
121
|
+
kind: "peer_message",
|
|
122
|
+
fromScope: "team/a b",
|
|
123
|
+
gateReceiptId: "gr-9",
|
|
124
|
+
};
|
|
125
|
+
await s.append(S, "a1", { content: "typed", sentAt: 1_000, peerMeta: full });
|
|
126
|
+
await s.append(S, "a1", { content: "plain", sentAt: 1_001 });
|
|
127
|
+
await s.append(S, "a1", { content: "empty-meta", sentAt: 1_002, peerMeta: {} });
|
|
128
|
+
const lease = await s.claimLease(S, "a1", "w1", 60_000, 10_000);
|
|
129
|
+
assert.deepEqual(lease.messages.map((m) => m.content), ["typed", "plain", "empty-meta"]);
|
|
130
|
+
assert.deepEqual(lease.messages[0].peerMeta, full, "六字段逐字节回读");
|
|
131
|
+
assert.equal("peerMeta" in lease.messages[1], false, "未带 peerMeta 的记录回读时该键必须缺席(不是 undefined 值)");
|
|
132
|
+
assert.deepEqual(lease.messages[2].peerMeta, {}, "空对象=在场但无字段,与缺席区分");
|
|
133
|
+
});
|
|
134
|
+
run("peerMeta 坏值响亮:garbage 以具名码拒 append,且零副作用(不入箱、不耗 seq、不动租约)", async (make) => {
|
|
135
|
+
const s = make();
|
|
136
|
+
const seqBefore = await s.append(S, "a1", { content: "anchor", sentAt: 1_000 });
|
|
137
|
+
const held = await s.claimLease(S, "a1", "holder", 60_000, 10_000);
|
|
138
|
+
assert.equal(held?.messages.length, 1);
|
|
139
|
+
const codeOf = (e) => e?.code;
|
|
140
|
+
class Meta {
|
|
141
|
+
fromSession = "s";
|
|
142
|
+
}
|
|
143
|
+
const hiddenKey = { fromSession: "s" };
|
|
144
|
+
Object.defineProperty(hiddenKey, "bogus", { value: 1, enumerable: false });
|
|
145
|
+
for (const [label, bad] of [
|
|
146
|
+
["fromMode 集外值", { fromMode: "root" }],
|
|
147
|
+
["kind 集外值", { kind: "gossip" }],
|
|
148
|
+
["未知键", { fromSession: "s", bogus: 1 }],
|
|
149
|
+
["空字符串", { senderKey: "" }],
|
|
150
|
+
["非字符串", { gateReceiptId: 7 }],
|
|
151
|
+
["数组", ["fromSession"]],
|
|
152
|
+
["null", null],
|
|
153
|
+
["字符串", "fromSession=x"],
|
|
154
|
+
["Date", new Date(0)],
|
|
155
|
+
["Map", new Map()],
|
|
156
|
+
["class 实例", new Meta()],
|
|
157
|
+
["symbol 键", { fromSession: "s", [Symbol("hidden")]: 1 }],
|
|
158
|
+
["不可枚举的未知键", hiddenKey],
|
|
159
|
+
]) {
|
|
160
|
+
let thrown;
|
|
161
|
+
try {
|
|
162
|
+
await s.append(S, "a1", { content: `bad:${label}`, sentAt: 2_000, peerMeta: bad });
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
thrown = e;
|
|
166
|
+
}
|
|
167
|
+
assert.notEqual(thrown, undefined, `${label}:必须拒`);
|
|
168
|
+
assert.equal(codeOf(thrown), MAILBOX_INVALID_PEER_META_CODE, `${label}:拒必须带码`);
|
|
169
|
+
}
|
|
170
|
+
assert.equal(await s.peekCount(S, "a1"), 1, "被拒的 append 不得入箱");
|
|
171
|
+
assert.equal(await s.claimLease(S, "a1", "rival", 60_000, 11_000), null, "被拒的 append 不得动活租约");
|
|
172
|
+
await s.ack(S, "a1", "holder", held.maxSeq);
|
|
173
|
+
assert.equal(await s.append(S, "a1", { content: "next", sentAt: 3_000 }), seqBefore + 1, "被拒的 append 不得消耗 seq");
|
|
174
|
+
});
|
|
175
|
+
run("peerMeta 不串引用:消费者改写租约批里的 peerMeta,不得改到下次投递的记录", async (make) => {
|
|
176
|
+
const s = make();
|
|
177
|
+
await s.append(S, "a1", { content: "m1", sentAt: 1_000, peerMeta: { fromMode: "bypass", fromSession: "sess_1" } });
|
|
178
|
+
const first = await s.claimLease(S, "a1", "w1", 1_000, 10_000);
|
|
179
|
+
first.messages[0].peerMeta.fromSession = "forged";
|
|
180
|
+
await s.releaseLease(S, "a1", "w1");
|
|
181
|
+
const again = await s.claimLease(S, "a1", "w2", 1_000, 20_000);
|
|
182
|
+
assert.deepEqual(again.messages[0].peerMeta, { fromMode: "bypass", fromSession: "sess_1" }, "重投的记录必须是原样");
|
|
183
|
+
});
|
|
115
184
|
await settle();
|
|
116
185
|
}
|
|
117
186
|
export async function mailboxAckOwnershipContract(mk, runAssertion) {
|
|
@@ -192,6 +261,93 @@ export async function mailboxBundledOnlyContract(mk, runAssertion) {
|
|
|
192
261
|
});
|
|
193
262
|
await settle();
|
|
194
263
|
}
|
|
264
|
+
export async function mailboxCrossProcessContract(hooks) {
|
|
265
|
+
const { run: runRaw, settle } = beginContract(hooks.runAssertion);
|
|
266
|
+
const run = (name, fn) => runRaw(name, async () => {
|
|
267
|
+
const pair = await hooks.openPair();
|
|
268
|
+
try {
|
|
269
|
+
await fn(pair.local, pair.peer);
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await pair.dispose();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
const S = MAILBOX_CONTRACT_SCOPE;
|
|
276
|
+
const H = "session.shared";
|
|
277
|
+
const range = (n) => Array.from({ length: n }, (_, i) => i + 1);
|
|
278
|
+
run("跨进程 seq 单铸:两个进程交错 append N+N ⇒ seq 恰为 1..2N(无重复无空洞),每条消息在任一方视角都可见", async (local, peer) => {
|
|
279
|
+
const N = 40;
|
|
280
|
+
const seqs = [];
|
|
281
|
+
for (let i = 0; i < N; i++) {
|
|
282
|
+
const [a, b] = await Promise.all([local.append(S, H, msg(`local-${i}`, 1_000 + i)), peer.append(S, H, msg(`peer-${i}`, 2_000 + i))]);
|
|
283
|
+
seqs.push(a, b);
|
|
284
|
+
}
|
|
285
|
+
assert.deepEqual([...seqs].sort((x, y) => x - y), range(2 * N), "两方拿到的回执合起来必须恰是 1..2N");
|
|
286
|
+
assert.equal(await peer.peekCount(S, H), 2 * N, "对方进程的观察面必须看到本方的 append");
|
|
287
|
+
assert.equal(await local.peekCount(S, H), 2 * N);
|
|
288
|
+
const lease = await local.claimLease(S, H, "cycle-1", 60_000, 10_000);
|
|
289
|
+
assert.deepEqual(lease.messages.map((m) => m.seq), range(2 * N), "本方 claim 必须按 seq 序取到全部 2N 条");
|
|
290
|
+
assert.equal(lease.maxSeq, 2 * N);
|
|
291
|
+
const contents = new Set(lease.messages.map((m) => m.content));
|
|
292
|
+
for (let i = 0; i < N; i++) {
|
|
293
|
+
assert.ok(contents.has(`local-${i}`), `local-${i} 不得丢`);
|
|
294
|
+
assert.ok(contents.has(`peer-${i}`), `peer-${i} 不得丢`);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
run("lease/ack 不跨进程交叉:一方的活租约 fence 住另一方的 claim;非持有方的 ack 是 no-op;持有方的 ack 对另一方立即可见;高水位跨对方的 ack 存活", async (local, peer) => {
|
|
298
|
+
await local.append(S, H, msg("m1", 1));
|
|
299
|
+
await peer.append(S, H, msg("m2", 2));
|
|
300
|
+
const held = await peer.claimLease(S, H, "cycle-peer", 60_000, 10_000);
|
|
301
|
+
assert.deepEqual(held.messages.map((m) => m.seq), [1, 2]);
|
|
302
|
+
assert.equal(await local.claimLease(S, H, "cycle-local", 60_000, 11_000), null, "对方进程的活租约必须 fence 住本方");
|
|
303
|
+
await local.ack(S, H, "cycle-local", held.maxSeq);
|
|
304
|
+
assert.equal(await peer.peekCount(S, H), 2, "非持有方的 ack 不得动持有方手上的批");
|
|
305
|
+
await peer.ack(S, H, "cycle-peer", held.maxSeq);
|
|
306
|
+
assert.equal(await local.peekCount(S, H), 0, "持有方的 ack 必须被本方看见");
|
|
307
|
+
assert.equal(await local.claimLease(S, H, "cycle-local", 60_000, 12_000), null);
|
|
308
|
+
assert.equal(await local.append(S, H, msg("m3", 3)), 3, "seq 高水位必须跨对方进程的 ack 存活(永不复用)");
|
|
309
|
+
await peer.append(S, H, msg("m4", 4));
|
|
310
|
+
const held2 = await peer.claimLease(S, H, "cycle-peer-2", 1_000, 20_000);
|
|
311
|
+
assert.deepEqual(held2.messages.map((m) => m.seq), [3, 4]);
|
|
312
|
+
const takeover = await local.claimLease(S, H, "cycle-local-2", 60_000, 30_000);
|
|
313
|
+
assert.deepEqual(takeover.messages.map((m) => m.seq), [3, 4], "过期租约的接管跨进程成立,seq 原样");
|
|
314
|
+
});
|
|
315
|
+
run("一方的 housekeeping 不丢另一方的 append:两进程各自反复压缩/重写期间交错写入 120 条,任一方视角都完整", async (local, peer) => {
|
|
316
|
+
const N = 60;
|
|
317
|
+
for (let i = 0; i < N; i++) {
|
|
318
|
+
await Promise.all([local.append(S, H, msg(`L${i}`, 1_000 + i)), peer.append(S, H, msg(`P${i}`, 2_000 + i))]);
|
|
319
|
+
}
|
|
320
|
+
const fromPeer = await peer.claimLease(S, H, "cycle-peer", 60_000, 10_000);
|
|
321
|
+
assert.deepEqual(fromPeer.messages.map((m) => m.seq), range(2 * N), "对方视角:seq 1..2N 全在");
|
|
322
|
+
await peer.releaseLease(S, H, "cycle-peer");
|
|
323
|
+
const fromLocal = await local.claimLease(S, H, "cycle-local", 60_000, 11_000);
|
|
324
|
+
assert.deepEqual(fromLocal.messages.map((m) => m.seq), range(2 * N), "本方视角:seq 1..2N 全在");
|
|
325
|
+
const contents = fromLocal.messages.map((m) => m.content).sort();
|
|
326
|
+
assert.deepEqual(contents, [...range(N).map((i) => `L${i - 1}`), ...range(N).map((i) => `P${i - 1}`)].sort());
|
|
327
|
+
await local.ack(S, H, "cycle-local", fromLocal.maxSeq);
|
|
328
|
+
assert.equal(await peer.peekCount(S, H), 0, "本方的 ack 对方可见");
|
|
329
|
+
});
|
|
330
|
+
run("reap 看得见对方进程的旧消息,哪怕本方缓存里这个箱是空的(已加载的空缓存不得替对方的旧消息说「没有」)", async (local, peer) => {
|
|
331
|
+
await local.append(S, H, msg("mine", 1_000));
|
|
332
|
+
const held = await local.claimLease(S, H, "cycle-local", 60_000, 1_000);
|
|
333
|
+
await local.ack(S, H, "cycle-local", held.maxSeq);
|
|
334
|
+
await peer.append(S, H, msg("old-from-peer", 2_000));
|
|
335
|
+
assert.equal(await local.reap(S, 100_000, { maxAgeMs: 50_000 }), 1, "对方写的过期消息必须被本方 reap 看见并老化");
|
|
336
|
+
assert.equal(await peer.peekCount(S, H), 0);
|
|
337
|
+
assert.equal(await peer.append(S, H, msg("after", 200_000)), 3, "高水位跨 reap 存活(对方视角)");
|
|
338
|
+
});
|
|
339
|
+
run("reap 与 drop 的 seq 语义跨进程:一方 reap 后另一方继续高水位;一方 drop 后另一方从 1 重铸", async (local, peer) => {
|
|
340
|
+
await peer.append(S, H, msg("old", 1_000));
|
|
341
|
+
assert.equal(await local.reap(S, 100_000, { maxAgeMs: 50_000 }), 1, "本方 reap 必须看见对方写的旧消息");
|
|
342
|
+
assert.equal(await peer.peekCount(S, H), 0);
|
|
343
|
+
assert.equal(await peer.append(S, H, msg("after-reap", 200_000)), 2, "reap 清箱但保高水位(RB-90 跨进程)");
|
|
344
|
+
await local.drop(S, H);
|
|
345
|
+
assert.equal(await peer.peekCount(S, H), 0, "本方 drop 对方可见");
|
|
346
|
+
assert.equal(await peer.append(S, H, msg("after-drop", 300_000)), 1, "drop 结束箱的一生:对方从 1 重铸");
|
|
347
|
+
assert.equal(await local.peekCount(S, H), 1);
|
|
348
|
+
});
|
|
349
|
+
await settle();
|
|
350
|
+
}
|
|
195
351
|
export async function mailboxTombstonedRecipientContract(mk, hooks) {
|
|
196
352
|
const { run: runRaw, settle } = beginContract(hooks.runAssertion);
|
|
197
353
|
const run = (name, fn) => runRaw(name, () => withStores(mk, fn));
|
|
@@ -140,6 +140,60 @@ export interface TaskNotificationPayload {
|
|
|
140
140
|
peer?: {
|
|
141
141
|
hopChain: string[];
|
|
142
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* design/385 §1.4 d1 — the delegated-child → parent UPLINK carrier (`SendMessage("main")`). Its
|
|
145
|
+
* PRESENCE is the render discriminator: the frame reaches the parent's model as a top-level
|
|
146
|
+
* `<agent-message from="…">` user frame (the same-process lane's carrier, distinct from both the
|
|
147
|
+
* `<task-notification>` shell and the cross-session envelope) followed by the peer discipline block.
|
|
148
|
+
* `body` is the child's message as bounded by the producer (no discipline block inside it — the
|
|
149
|
+
* block is frame-adjacent by rule). The classic members (`summary`/`result`) stay filled beside it
|
|
150
|
+
* for wire consumers that project the frame as a card; they are not what the model reads.
|
|
151
|
+
* Minted ONLY by the SendMessage uplink leg (engine-side); an external `notify()` cannot wear it.
|
|
152
|
+
*/
|
|
153
|
+
agentMessage?: {
|
|
154
|
+
from: string;
|
|
155
|
+
body: string;
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* design/385 §4.1 — the CROSS-SESSION carrier (a peer session's message drained from this session's
|
|
159
|
+
* own box). Its PRESENCE is the render discriminator: the model reads a top-level
|
|
160
|
+
* `<cross-session-message from="…" …>` envelope (design/385 §4.2 model-face codec — from-session /
|
|
161
|
+
* from-name / from-mode / from-scope as the typed record carried them; the hop chain never appears)
|
|
162
|
+
* followed by the cross-session discipline block, never a `<task-notification>` shell. The
|
|
163
|
+
* attributes are a PROJECTION of the store record's typed `peerMeta` — the text is presentation,
|
|
164
|
+
* the typed side record ({@link _sema_provenance}) is the authority. Minted ONLY by the engine's
|
|
165
|
+
* session-box drain; an external `notify()` cannot wear it.
|
|
166
|
+
*/
|
|
167
|
+
crossSessionMessage?: import("../agents/cross-session-envelope.js").CrossSessionEnvelopeFields & {
|
|
168
|
+
body: string;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* design/385 §1.4 d1 / §4.1 — the engine-minted PROVENANCE side record of an engine-injected peer
|
|
172
|
+
* frame, a typed key (never model text) so a host can attribute and correlate the injection on the
|
|
173
|
+
* wire. Present exactly when {@link agentMessage} OR {@link crossSessionMessage} is — one record
|
|
174
|
+
* per carrier, `kind` naming which. Its fields are read per `kind` (see {@link SemaProvenance}):
|
|
175
|
+
* on the agent-message lane `from` is the sender label the frame's attribute spells, `taskId` the
|
|
176
|
+
* sender's run/agent id, `seq` the producer's per-frame counter (= this payload's `seq`) and
|
|
177
|
+
* `agentType` the sender's resolved agent type when the producer knows it; on the cross-session
|
|
178
|
+
* lane `from` is the sender's ADDRESS, `taskId` the recipient's own box handle, `seq` the box seq,
|
|
179
|
+
* and the typed peer record rides in `peerMeta`.
|
|
180
|
+
*/
|
|
181
|
+
_sema_provenance?: SemaProvenance;
|
|
182
|
+
}
|
|
183
|
+
/** design/385 §1.4 d1 / §4.1 — see {@link TaskNotificationPayload._sema_provenance}. `kind` is a
|
|
184
|
+
* closed set of the two engine lanes that mint the record; a future lane adds a member, never a
|
|
185
|
+
* second key. */
|
|
186
|
+
export interface SemaProvenance {
|
|
187
|
+
/** `agent_message` = the same-process uplink (§1.4 d1); `cross_session_message` = the session-box
|
|
188
|
+
* drain (§4.1) — `from` is then the sender's ADDRESS, `taskId` the recipient's own box handle,
|
|
189
|
+
* `seq` the box seq, and the typed peer record rides in `peerMeta`. */
|
|
190
|
+
kind: "agent_message" | "cross_session_message";
|
|
191
|
+
from: string;
|
|
192
|
+
taskId: string;
|
|
193
|
+
seq: number;
|
|
194
|
+
agentType?: string;
|
|
195
|
+
/** cross_session_message only — the store record's typed peer metadata, verbatim (authority). */
|
|
196
|
+
peerMeta?: import("./mailbox-store.js").MailboxPeerMeta;
|
|
143
197
|
}
|
|
144
198
|
/**
|
|
145
199
|
* design/144 §2 — the caller-facing input of `TaskStream.notify()`: a STRUCTURED external event to inject
|
|
@@ -229,6 +283,30 @@ export declare function attrEscape(value: string): string;
|
|
|
229
283
|
/** Max rendered length of an untrusted attribution label (the external `from="…"` header and the
|
|
230
284
|
* design/171 speaker envelope share it — same concern: a display label, not a payload). */
|
|
231
285
|
export declare const EXTERNAL_SOURCE_MAX = 120;
|
|
286
|
+
/** design/385 §1.4 d1 — the same-process lane's carrier tag (CC `iTe`). */
|
|
287
|
+
export declare const AGENT_MESSAGE_TAG = "agent-message";
|
|
288
|
+
/**
|
|
289
|
+
* design/385 §1.4 d1 — render the child → parent uplink as CC's same-process form: a top-level
|
|
290
|
+
* `<agent-message from="…">` frame (CC `ZSe`: attribute-escaped sender, nested-tag-neutralized body,
|
|
291
|
+
* no header prose — the attribution IS the attribute) followed by the peer discipline block OUTSIDE
|
|
292
|
+
* the frame (design/176 §3.4 placement: a sender-embedded copy inside the body arrives neutralized,
|
|
293
|
+
* so position distinguishes the real block). The body first passes the harness AUTHORITY-family
|
|
294
|
+
* neutralization ({@link neutralizePeerBody}: every engine envelope a model reads as harness speech —
|
|
295
|
+
* `task-notification`, `user_memory`, `skills`, … — not the reminder tag alone), because the child's
|
|
296
|
+
* text is model output; the pre-carrier `<task-notification>` shell entity-escaped every byte of it,
|
|
297
|
+
* and the carrier form must contain at least as much.
|
|
298
|
+
*/
|
|
299
|
+
export declare function renderAgentMessageFrame(frame: {
|
|
300
|
+
from: string;
|
|
301
|
+
body: string;
|
|
302
|
+
}): string;
|
|
303
|
+
/**
|
|
304
|
+
* design/385 §4.1 — render a drained peer-session message as the model-face envelope (the §4.2
|
|
305
|
+
* codec: canonical attribute order, neutralized body) followed by the cross-session discipline block
|
|
306
|
+
* OUTSIDE the envelope. Distinct from {@link renderAgentMessageFrame}: another lane, another trust
|
|
307
|
+
* level, another block — sharing either would blur the classifier's lane judgment.
|
|
308
|
+
*/
|
|
309
|
+
export declare function renderCrossSessionMessageFrame(frame: NonNullable<TaskNotificationPayload["crossSessionMessage"]>): string;
|
|
232
310
|
export declare function renderTaskNotificationXml(n: TaskNotificationPayload): string;
|
|
233
311
|
/**
|
|
234
312
|
* The BETWEEN-TURNS pending lane. A task notification born while NO turn is
|
|
@@ -341,11 +419,21 @@ export declare class PendingSessionNotifications {
|
|
|
341
419
|
get size(): number;
|
|
342
420
|
}
|
|
343
421
|
/**
|
|
344
|
-
* Delivery-side overflow disclosure: fold the per-task drop counts into the drained payloads.
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
422
|
+
* Delivery-side overflow disclosure: fold the per-task drop counts into the drained payloads. Two
|
|
423
|
+
* carrier shapes, chosen per survivor:
|
|
424
|
+
* · a CLASSIC survivor (renders through the `<task-notification>` shell) is annotated IN PLACE — the
|
|
425
|
+
* first surviving payload of a task that lost events gets a `[task_id]`-prefixed disclosure line
|
|
426
|
+
* prepended to its summary (backgroundTasks 同规: the id keeps the loss addressable via TaskOutput);
|
|
427
|
+
* · an AGENT-MESSAGE survivor (design/385 §1.4 d1; renders as the `<agent-message>` carrier, its
|
|
428
|
+
* `summary` wire-only) is left byte-unchanged, and the disclosure rides as a SEPARATE engine-authored
|
|
429
|
+
* `event` payload inserted immediately AHEAD of it (engine speech never goes inside a peer frame;
|
|
430
|
+
* peer speech never goes inside the authority shell that line renders through).
|
|
431
|
+
* The returned array is therefore NOT a 1:1 image of `drained.items`: it can be longer (one inserted
|
|
432
|
+
* line per disclosed agent-message lane, plus the per-lane "nothing survived" lines and the
|
|
433
|
+
* whole-session line below) — consume it by iteration, never by index-pairing against `items`. The
|
|
434
|
+
* zero-disclosure path returns `drained.items` itself (identity, no allocation). A task whose EVERY
|
|
435
|
+
* pending item was evicted still gets one honest synthetic `event` payload saying so — a fully silent
|
|
436
|
+
* loss is never allowed.
|
|
349
437
|
*/
|
|
350
438
|
export declare function discloseDroppedPending(drained: DrainedPendingNotifications): TaskNotificationPayload[];
|
|
351
439
|
export declare class SystemInjectionQueue<TPayload = TaskNotificationPayload> {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
|
|
1
|
+
import { escapeEnvelopeTag, inlineUntrusted, sanitizeUntrustedText } from "./untrusted-text.js";
|
|
2
|
+
import { PEER_MESSAGE_NOTICE } from "../agents/peer-admission.js";
|
|
3
|
+
import { buildCrossSessionEnvelope, CROSS_SESSION_MESSAGE_NOTICE, neutralizePeerBody } from "../agents/cross-session-envelope.js";
|
|
2
4
|
export const SYSTEM_INJECTION_PRIORITIES = ["now", "next", "later"];
|
|
3
5
|
export function isSystemInjectionPriority(value) {
|
|
4
6
|
return typeof value === "string" && SYSTEM_INJECTION_PRIORITIES.includes(value);
|
|
@@ -46,7 +48,20 @@ export function attrEscape(value) {
|
|
|
46
48
|
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
47
49
|
}
|
|
48
50
|
export const EXTERNAL_SOURCE_MAX = 120;
|
|
51
|
+
export const AGENT_MESSAGE_TAG = "agent-message";
|
|
52
|
+
export function renderAgentMessageFrame(frame) {
|
|
53
|
+
const body = escapeEnvelopeTag(AGENT_MESSAGE_TAG, neutralizePeerBody(frame.body));
|
|
54
|
+
return `<${AGENT_MESSAGE_TAG} from="${attrEscape(frame.from)}">\n${body}\n</${AGENT_MESSAGE_TAG}>\n\n${PEER_MESSAGE_NOTICE}`;
|
|
55
|
+
}
|
|
56
|
+
export function renderCrossSessionMessageFrame(frame) {
|
|
57
|
+
const { body, ...fields } = frame;
|
|
58
|
+
return `${buildCrossSessionEnvelope(fields, body)}\n\n${CROSS_SESSION_MESSAGE_NOTICE}`;
|
|
59
|
+
}
|
|
49
60
|
export function renderTaskNotificationXml(n) {
|
|
61
|
+
if (n.agentMessage !== undefined)
|
|
62
|
+
return renderAgentMessageFrame(n.agentMessage);
|
|
63
|
+
if (n.crossSessionMessage !== undefined)
|
|
64
|
+
return renderCrossSessionMessageFrame(n.crossSessionMessage);
|
|
50
65
|
const usage = n.usage === undefined
|
|
51
66
|
? undefined
|
|
52
67
|
: (() => {
|
|
@@ -196,12 +211,24 @@ export function discloseDroppedPending(drained) {
|
|
|
196
211
|
if (drained.dropped.size === 0)
|
|
197
212
|
return [...drained.items, ...sessionLine];
|
|
198
213
|
const disclosed = new Set();
|
|
199
|
-
const out = drained.items.
|
|
214
|
+
const out = drained.items.flatMap((n) => {
|
|
200
215
|
const lane = taskNotificationLaneKey(n);
|
|
201
216
|
const dropped = drained.dropped.get(lane);
|
|
202
217
|
if (dropped === undefined || disclosed.has(lane))
|
|
203
|
-
return n;
|
|
218
|
+
return [n];
|
|
204
219
|
disclosed.add(lane);
|
|
220
|
+
if (n.agentMessage !== undefined || n.crossSessionMessage !== undefined) {
|
|
221
|
+
const line = {
|
|
222
|
+
task_id: n.task_id,
|
|
223
|
+
task_type: n.task_type,
|
|
224
|
+
status: "event",
|
|
225
|
+
summary: `[${n.task_id}] ${dropped.count} earlier pending notification(s) from this task were dropped (pending-queue overflow); its latest message follows.`,
|
|
226
|
+
};
|
|
227
|
+
const linePriority = drained.priorities?.get(n);
|
|
228
|
+
if (linePriority !== undefined)
|
|
229
|
+
drained.priorities?.set(line, linePriority);
|
|
230
|
+
return [line, n];
|
|
231
|
+
}
|
|
205
232
|
const annotated = {
|
|
206
233
|
...n,
|
|
207
234
|
summary: `[${n.task_id}] ${dropped.count} earlier pending notification(s) from this task were dropped (pending-queue overflow). ${n.summary}`,
|
|
@@ -209,7 +236,7 @@ export function discloseDroppedPending(drained) {
|
|
|
209
236
|
const priority = drained.priorities?.get(n);
|
|
210
237
|
if (priority !== undefined)
|
|
211
238
|
drained.priorities?.set(annotated, priority);
|
|
212
|
-
return annotated;
|
|
239
|
+
return [annotated];
|
|
213
240
|
});
|
|
214
241
|
for (const [lane, dropped] of drained.dropped) {
|
|
215
242
|
if (disclosed.has(lane))
|
|
@@ -1058,6 +1058,17 @@ export interface AskRequest {
|
|
|
1058
1058
|
* from it across a coverage change — harmlessly, since the record is what gets confirmed.
|
|
1059
1059
|
*/
|
|
1060
1060
|
readonly ruleOffers?: readonly import("./permission-rule-model.js").RuleOffer[];
|
|
1061
|
+
/**
|
|
1062
|
+
* design/382 §2.4 (adversarial-review r3, additive) — the RELATIVE-CD RESOLUTION BASE the offers
|
|
1063
|
+
* above were minted with: the live tracked working directory at adjudication time (the RB-108
|
|
1064
|
+
* value; equal to the task root until an observable `cd` moves the tracker). Present only beside
|
|
1065
|
+
* {@link ruleOffers} when the run has a tracked cwd. A consumer preparing the AUTHORITATIVE
|
|
1066
|
+
* consent record threads it as `prepareCardApproval`'s `execCwd`, so the record re-mints the SAME
|
|
1067
|
+
* directory member this projection displayed — without it, a moved tracker would make the record
|
|
1068
|
+
* resolve `cd ./x` from the task root and persist a rule for a directory the command never
|
|
1069
|
+
* enters. Display/reconstruction context only, never adjudication input.
|
|
1070
|
+
*/
|
|
1071
|
+
readonly execCwd?: string;
|
|
1061
1072
|
/**
|
|
1062
1073
|
* #490 修② — WHY {@link ruleOffers} is absent, when the rule-offer lane is in play and has nothing
|
|
1063
1074
|
* to give. A CLOSED set, mutually exclusive with {@link ruleOffers} (never both, never neither once
|