@sema-agent/core 7.24.4 → 7.25.1
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 +100 -0
- package/dist/agents/peer-session-drain.d.ts +16 -1
- package/dist/agents/peer-session-drain.js +16 -1
- package/dist/agents/send-message-tool.d.ts +10 -2
- package/dist/agents/send-message-tool.js +18 -7
- package/dist/agents/subagent.js +13 -10
- package/dist/core/arg-summary.d.ts +0 -7
- package/dist/core/arg-summary.js +2 -1
- package/dist/core/background-agent-store.d.ts +3 -3
- package/dist/core/checkpoint-store.d.ts +1 -0
- package/dist/core/delegation-frames.d.ts +5 -0
- package/dist/core/gate-fold.js +85 -0
- package/dist/core/gate-lanes.js +23 -1
- package/dist/core/gate-outcome.d.ts +8 -2
- package/dist/core/gate-outcome.js +3 -1
- package/dist/core/hooks.d.ts +29 -0
- package/dist/core/hooks.js +2 -0
- package/dist/core/mailbox-store.d.ts +5 -0
- package/dist/core/mailbox-store.js +1 -1
- package/dist/core/parent-spec-seat.d.ts +7 -1
- package/dist/core/parent-spec-seat.js +2 -0
- package/dist/core/permission-rule-consent.js +29 -47
- package/dist/core/permission-rule-provider.js +4 -3
- package/dist/core/permission-rule-store.d.ts +143 -7
- package/dist/core/permission-rule-store.js +165 -15
- package/dist/core/runner/contracts.d.ts +36 -2
- package/dist/core/runner/gate-exit.d.ts +14 -0
- package/dist/core/runner/gate-exit.js +4 -0
- package/dist/core/runner/prepare-delegation-surface.d.ts +4 -4
- package/dist/core/runner/prepare-delegation-surface.js +2 -1
- package/dist/core/runner/prepare-gate-stations.d.ts +4 -0
- package/dist/core/runner/prepare-gate-stations.js +7 -4
- package/dist/core/runner/prepare-hands-readface.d.ts +8 -4
- package/dist/core/runner/prepare-hands-readface.js +25 -10
- package/dist/core/runner/prepare-inherited-gate.d.ts +23 -0
- package/dist/core/runner/prepare-inherited-gate.js +11 -6
- package/dist/core/runner/prepare-policy-chain.d.ts +15 -1
- package/dist/core/runner/prepare-policy-chain.js +14 -4
- package/dist/core/runner/prepare-suspend-saga.d.ts +5 -2
- package/dist/core/runner/prepare-suspend-saga.js +4 -3
- package/dist/core/runner/prepare-task.js +10 -9
- package/dist/core/runner/prepare-wiring-manifest.d.ts +11 -4
- package/dist/core/runner/prepare-wiring-manifest.js +8 -4
- package/dist/core/runner/resume-apply.js +2 -0
- package/dist/core/runner/run-harness-handlers.js +1 -0
- package/dist/core/runner/run-identity-wiring.d.ts +2 -2
- package/dist/core/runner/run-leg.js +26 -0
- package/dist/core/runner/run-notification-lane.d.ts +2 -2
- package/dist/core/runner/run-notification-lane.js +2 -1
- package/dist/core/runner/run-settle-and-teardown.js +1 -0
- package/dist/core/runner/session-rule-policy.d.ts +3 -4
- package/dist/core/runner/session-rule-policy.js +11 -3
- package/dist/core/session-policy-store.d.ts +27 -3
- package/dist/core/session-policy-store.js +12 -4
- package/dist/core/store-contracts/background-agent-store-contract.js +10 -0
- package/dist/core/store-contracts/peer-directory-contract.d.ts +23 -0
- package/dist/core/store-contracts/peer-directory-contract.js +82 -0
- package/dist/core/task-event.d.ts +6 -0
- package/dist/core/task-notification.d.ts +79 -4
- package/dist/core/task-notification.js +90 -1
- package/dist/core/task-registry-agent.js +1 -2
- package/dist/core/task-registry-monitor.js +2 -1
- package/dist/core/task-registry-shared.d.ts +53 -5
- package/dist/core/task-registry-shared.js +13 -2
- package/dist/core/task-registry.js +8 -5
- package/dist/core/task-spec.d.ts +12 -5
- package/dist/core/tool-catalog-entries.js +1 -0
- package/dist/core/tool-policy.js +30 -23
- package/dist/core/tool-spec.d.ts +1 -1
- package/dist/core/wiring-manifest.d.ts +2 -2
- package/dist/engine/loop/agent-loop.js +1 -1
- package/dist/engine/loop/types.d.ts +4 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -3
- package/dist/prompts/coordinator.d.ts +2 -1
- package/dist/stores/file/adoption/adopt.js +23 -1
- package/dist/stores/file/permission-rule-store.js +3 -1
- package/dist/stores/file/session-policy-store.d.ts +31 -0
- package/dist/stores/file/session-policy-store.js +87 -7
- package/dist/tools/fs/bash-lexer.d.ts +13 -0
- package/dist/tools/fs/bash-lexer.js +61 -0
- package/dist/tools/fs/bash-program-position.d.ts +36 -29
- package/dist/tools/fs/bash-program-position.js +33 -13
- package/dist/tools/fs/bash-readonly-classifier.d.ts +26 -313
- package/dist/tools/fs/bash-readonly-classifier.js +81 -782
- package/dist/tools/fs/fs-bash.d.ts +43 -207
- package/dist/tools/fs/fs-bash.js +20 -341
- package/dist/tools/fs/index.d.ts +12 -13
- package/dist/tools/fs/index.js +3 -4
- package/dist/tools/fs/read-deny.d.ts +25 -0
- package/dist/tools/fs/read-deny.js +19 -0
- package/dist/tools/fs/safety.js +2 -6
- package/dist/tools/fs/shell-read-check.d.ts +99 -0
- package/dist/tools/fs/shell-read-check.js +229 -0
- package/dist/tools/fs/shell-read-establish.d.ts +70 -115
- package/dist/tools/fs/shell-read-establish.js +216 -102
- package/dist/tools/fs/shell-read-judge.d.ts +231 -0
- package/dist/tools/fs/shell-read-judge.js +170 -0
- package/dist/tools/fs/shell-read-plan.d.ts +3 -0
- package/dist/tools/fs/shell-read-plan.js +88 -27
- package/dist/tools/monitor.d.ts +5 -5
- package/dist/tools/monitor.js +3 -5
- package/dist/tools/task-list.d.ts +4 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +19 -3
- package/wire-changes/core@7.25.0.json +671 -0
- package/wire-changes/core@7.25.1.json +64 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PeerInboundChainRef } from "../../agents/peer-admission.js";
|
|
2
|
-
import { type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
2
|
+
import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
3
3
|
import type { PushQueue } from "../push-queue.js";
|
|
4
4
|
import type { TaskEvent, TaskSpec } from "../types.js";
|
|
5
5
|
import type { Stats } from "./assemble-result.js";
|
|
@@ -32,7 +32,7 @@ export interface RunIdentityWiringInput {
|
|
|
32
32
|
/** borrowed-readonly — the notification lane's ONE injection entry (the R0 lane's). */
|
|
33
33
|
injectTaskNotification: (notification: TaskNotificationPayload, opts?: {
|
|
34
34
|
priority?: SystemInjectionPriority;
|
|
35
|
-
}) => Promise<
|
|
35
|
+
}) => Promise<NotificationInjectionOutcome>;
|
|
36
36
|
/** borrowed-mutable — the turn-open dedup set (the cross-layer double-park dedup): a redelivered frame's key is registered here. */
|
|
37
37
|
deliveredAtTurnOpen: Set<string>;
|
|
38
38
|
/** borrowed-mutable — the peer inbound chain, overwritten by each consumed peer-class payload. */
|
|
@@ -19,6 +19,7 @@ import { DEFAULT_PRECALL_OUTPUT_TOKENS, platformLimitTerminal } from "./clock-an
|
|
|
19
19
|
import { deepJsonEqual, resumeContinuation } from "./decide-continuation.js";
|
|
20
20
|
import { settleExecutionRecord } from "./execution-record.js";
|
|
21
21
|
import { mintGateOutcome } from "./gate-exit.js";
|
|
22
|
+
import { withGateTimeout } from "../gate-bounded-await.js";
|
|
22
23
|
import { resolveGitLegDelivery, wrapGitFrame } from "./git-leg-delivery.js";
|
|
23
24
|
import { GIT_STATUS_ECHO_PREVIEW } from "./git-status-frame.js";
|
|
24
25
|
import { toImageContent } from "./image.js";
|
|
@@ -678,6 +679,31 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
|
|
|
678
679
|
return;
|
|
679
680
|
}
|
|
680
681
|
}
|
|
682
|
+
if (prepared.shellReadCheck !== undefined) {
|
|
683
|
+
const edited = outcome.updatedInput !== undefined && !deepJsonEqual(outcome.updatedInput, pendingAction.args);
|
|
684
|
+
const judged = prepared.shellReadCheck.judge(pendingAction.toolName, resolvedArgs, prepared.cwdRef?.current, prepared.abortController.signal, {
|
|
685
|
+
toolCallId: pendingAction.toolCallId,
|
|
686
|
+
...(edited ? { seat: "rejudge" } : {}),
|
|
687
|
+
});
|
|
688
|
+
if (judged !== null) {
|
|
689
|
+
let verdict;
|
|
690
|
+
let answered = true;
|
|
691
|
+
try {
|
|
692
|
+
verdict = await withGateTimeout(judged, ORG_ADJUDICATION_TIMEOUT_MS, prepared.abortController.signal, "shellReadStation");
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
answered = false;
|
|
696
|
+
verdict = prepared.shellReadCheck.unanswered(pendingAction.toolName, "the read station could not answer on the resumed leg");
|
|
697
|
+
}
|
|
698
|
+
if (!answered || verdict.disposition === "refuse" || (edited && verdict.disposition !== "vouch")) {
|
|
699
|
+
const readDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: ${verdict.sentence ?? "the read boundary does not vouch for what it reads on this leg"} This approval is spent — the call has to be re-issued and approved again.`, prepared.reminderMark);
|
|
700
|
+
await settleEnd(true, { content: readDenial, ...(verdict.details !== undefined ? { details: verdict.details } : {}) }, vetoed("read_boundary"));
|
|
701
|
+
const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, readDenial, true));
|
|
702
|
+
emitCommitted(eid, "toolResult", pendingAction.toolCallId);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
681
707
|
const tool = prepared.tools.find((t) => t.name === pendingAction.toolName);
|
|
682
708
|
if (!tool) {
|
|
683
709
|
const e = new Error(`the approved tool "${pendingAction.toolName}" is no longer available on resume`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type PeerInboundChainRef } from "../../agents/peer-admission.js";
|
|
2
2
|
import type { PushQueue } from "../push-queue.js";
|
|
3
3
|
import type { SessionStore } from "../session.js";
|
|
4
|
-
import { type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
4
|
+
import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
|
|
5
5
|
import type { RunnerDeps, TaskEvent, TaskSpec } from "../types.js";
|
|
6
6
|
import type { SessionReadFileStates } from "./prepare-hands-readface.js";
|
|
7
7
|
import type { NotificationLaneBindings, Prepared, PrepareTaskFn, ResumeRun, RunInternals, RunnerDepsSeat, RunnerSelfSeat, TaskIdRef } from "./contracts.js";
|
|
@@ -41,7 +41,7 @@ export interface RunNotificationLaneResult {
|
|
|
41
41
|
/** The ONE injection entry every notification producer rides (the identity lane binds the notify() bridge to it). */
|
|
42
42
|
injectTaskNotification: (notification: TaskNotificationPayload, opts?: {
|
|
43
43
|
priority?: SystemInjectionPriority;
|
|
44
|
-
}) => Promise<
|
|
44
|
+
}) => Promise<NotificationInjectionOutcome>;
|
|
45
45
|
/** The turn-open dedup set (the cross-layer double-park dedup): the identity lane registers a redelivered frame's key here. */
|
|
46
46
|
deliveredAtTurnOpen: Set<string>;
|
|
47
47
|
/** The peer inbound chain ref this run carries (the spawn chain's, or a fresh one). */
|
|
@@ -118,9 +118,10 @@ export async function runNotificationLane(input) {
|
|
|
118
118
|
}
|
|
119
119
|
catch {
|
|
120
120
|
}
|
|
121
|
-
injectTaskNotification(notification, opts);
|
|
121
|
+
return injectTaskNotification(notification, opts);
|
|
122
122
|
},
|
|
123
123
|
}, self, taskIdRef);
|
|
124
|
+
pendingSessionNotifications.bindSessionBox(prepared.sessionId, prepared.sessionBox);
|
|
124
125
|
const notificationLane = {
|
|
125
126
|
get harness() { return notificationHarness; }, set harness(h) { notificationHarness = h; },
|
|
126
127
|
get sessionId() { return notificationSessionId; },
|
|
@@ -266,6 +266,7 @@ export async function runSettleAndTeardown(input) {
|
|
|
266
266
|
...(internals?.cycleSeq !== undefined ? { seq: internals.cycleSeq } : {}),
|
|
267
267
|
...(internals?.parentTaskId !== undefined ? { parentTaskId: internals.parentTaskId } : {}),
|
|
268
268
|
...(subagentName ? { name: subagentName } : {}),
|
|
269
|
+
...(internals?.explicitAgentName !== undefined ? { spawnName: internals.explicitAgentName } : {}),
|
|
269
270
|
model: prepared.model.id,
|
|
270
271
|
usage: { totalTokens: stats.tokens, toolUses: stats.toolCalls, durationMs: Date.now() - rs.telemetry.taskStart },
|
|
271
272
|
status: result.terminal.kind === "completed" ? "completed" : "failed",
|
|
@@ -87,10 +87,9 @@ export interface SessionRuleSource {
|
|
|
87
87
|
* DELETED and written again starts over at 1. At `>=`, a recreated row whose rev merely TIES the
|
|
88
88
|
* enforced one would replace it — an unauthorized loosening dressed as an update. At `>`, a tie enforces
|
|
89
89
|
* the floor, which is a no-op for the honest case (equal rev is the same row) and fail-closed for the
|
|
90
|
-
* recycled one.
|
|
91
|
-
*
|
|
92
|
-
* a
|
|
93
|
-
* generation identity), which is a cross-backend contract change, not a reader-side one.
|
|
90
|
+
* recycled one. Closed since 7.25.0 by the STORE's lineage identity (`StoredSessionRules.gen`): a
|
|
91
|
+
* recreated row is another generation and is never compared to the enforced one, whatever its rev, and
|
|
92
|
+
* a backend without lineages (gen 0 throughout) keeps the `>` protection above.
|
|
94
93
|
*
|
|
95
94
|
* The per-call handoff mirrors the permission-rule lanes': the first reader of a `toolCallId` takes the read
|
|
96
95
|
* and hands it on, a second reader of the SAME call claims that one snapshot, and an unclaimed entry is
|
|
@@ -60,9 +60,17 @@ export function createLiveSessionRules(store, floor, hooks) {
|
|
|
60
60
|
}
|
|
61
61
|
return enforced;
|
|
62
62
|
}
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
if (enforced.rev > 0) {
|
|
64
|
+
if (live.gen !== undefined && enforced.gen !== undefined) {
|
|
65
|
+
if (live.gen !== enforced.gen) {
|
|
66
|
+
announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
|
|
67
|
+
return enforced;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
else if (vanished) {
|
|
71
|
+
announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev} after the row this leg enforced was deleted, and its backend carries no lineage to continue it by`);
|
|
72
|
+
return enforced;
|
|
73
|
+
}
|
|
66
74
|
}
|
|
67
75
|
if (live.rev <= enforced.rev) {
|
|
68
76
|
if (live.rev < enforced.rev) {
|
|
@@ -36,6 +36,11 @@ export interface SessionPermissionRules {
|
|
|
36
36
|
/** {@link SessionPermissionRules} plus the monotonic `rev` the store stamps (the OCC key). */
|
|
37
37
|
export interface StoredSessionRules extends SessionPermissionRules {
|
|
38
38
|
rev: number;
|
|
39
|
+
/** The row's LINEAGE: which generation of this key the row belongs to. `rev` counts within one lineage; an
|
|
40
|
+
* erase (`deleteBySession`) ends it, and the next write on the key starts the next generation, so a reader
|
|
41
|
+
* can tell a recreated row from a continuation of the one it enforces whatever `rev` the new row wears.
|
|
42
|
+
* Absent = generation 0 (a backend that predates the mark, or a row written before it). */
|
|
43
|
+
gen?: number;
|
|
39
44
|
}
|
|
40
45
|
/** Options for {@link SessionPolicyStore.putRules}. */
|
|
41
46
|
export interface PutRulesOptions {
|
|
@@ -47,10 +52,11 @@ export interface PutRulesOptions {
|
|
|
47
52
|
operator?: boolean;
|
|
48
53
|
}
|
|
49
54
|
/** Typed error from {@link SessionPolicyStore.putRules}. `conflict` = stale `expectedRev`; `loosen_forbidden`
|
|
50
|
-
* = a non-operator write tried to relax the rules
|
|
55
|
+
* = a non-operator write tried to relax the rules; `corrupt` = a backend could not read the erase-surviving
|
|
56
|
+
* rev mark ({@link SessionPolicyStore.deleteBySession}) and refuses rather than stamp an unproven rev. */
|
|
51
57
|
export declare class SessionPolicyError extends Error {
|
|
52
|
-
readonly code: "conflict" | "loosen_forbidden";
|
|
53
|
-
constructor(code: "conflict" | "loosen_forbidden", message: string);
|
|
58
|
+
readonly code: "conflict" | "loosen_forbidden" | "corrupt";
|
|
59
|
+
constructor(code: "conflict" | "loosen_forbidden" | "corrupt", message: string);
|
|
54
60
|
}
|
|
55
61
|
/**
|
|
56
62
|
* The seam a deployment implements for DURABLE, cross-replica per-session rules. Unset on `RunnerDeps` ⇒ the
|
|
@@ -80,6 +86,17 @@ export interface SessionPolicyStore {
|
|
|
80
86
|
* rows — without this seam they survive the overwrite and keep enforcing the old branch's rules against
|
|
81
87
|
* the new content. Optional like {@link listBySession} (a sync-capable backend implements both); the two
|
|
82
88
|
* bundled impls do. Idempotent: deleting a session with no records is a no-op.
|
|
89
|
+
*
|
|
90
|
+
* @contract session_policy.rev_survives_delete — a key's `rev` (one key = `(sessionId, principal)`, the unit
|
|
91
|
+
* a row is stored under) is a HIGH-WATER MARK that survives this erase: the first `putRules` on that key
|
|
92
|
+
* after a `deleteBySession` stamps a `rev` strictly greater than every rev the key ever carried, so no later
|
|
93
|
+
* row of that key reads a smaller `rev` than an earlier one (another principal's row on the same session
|
|
94
|
+
* counts on its own), and a
|
|
95
|
+
* tighten-only (non-operator) write landing after an erase cannot slip under a floor a reader already
|
|
96
|
+
* enforced. A backend that reads "no row" as rev 0 must therefore keep the mark past the row (the bundled
|
|
97
|
+
* in-memory store and a deployment's SQL twin both do). The erase also ends the row's LINEAGE: the next
|
|
98
|
+
* write on the key carries the next `gen`, so a reader that enforces a row treats a later row of another
|
|
99
|
+
* generation as not comparable whatever its rev — a recreated row cannot pass for a continuation.
|
|
83
100
|
*/
|
|
84
101
|
deleteBySession?(sessionId: string): Promise<void>;
|
|
85
102
|
}
|
|
@@ -123,6 +140,13 @@ export declare function stripRev(rules: SessionPermissionRules): SessionPermissi
|
|
|
123
140
|
*/
|
|
124
141
|
export declare class InMemorySessionPolicyStore implements SessionPolicyStore {
|
|
125
142
|
private readonly map;
|
|
143
|
+
/**
|
|
144
|
+
* The per-key mark {@link SessionPolicyStore.deleteBySession} may not erase: the largest `rev` this key has
|
|
145
|
+
* ever been stamped with, kept outside the row so removing the row cannot roll the counter back. Written by
|
|
146
|
+
* every stamp, read by the next one, never read by anything else, and never collected — an erase frees the
|
|
147
|
+
* row and keeps this, so the cost is one integer per distinct key the process has ever written.
|
|
148
|
+
*/
|
|
149
|
+
private readonly highWater;
|
|
126
150
|
private key;
|
|
127
151
|
getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
|
|
128
152
|
putRules(sessionId: string, principal: string | undefined, rules: SessionPermissionRules, opts?: PutRulesOptions): Promise<StoredSessionRules>;
|
|
@@ -76,6 +76,7 @@ export function stripRev(rules) {
|
|
|
76
76
|
}
|
|
77
77
|
export class InMemorySessionPolicyStore {
|
|
78
78
|
map = new Map();
|
|
79
|
+
highWater = new Map();
|
|
79
80
|
key(sessionId, principal) {
|
|
80
81
|
return JSON.stringify([sessionId, principal ?? null]);
|
|
81
82
|
}
|
|
@@ -96,8 +97,12 @@ export class InMemorySessionPolicyStore {
|
|
|
96
97
|
throw new SessionPolicyError("loosen_forbidden", `a non-operator session-rule write may only tighten; refused: ${loosens.join("; ")}`);
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
|
-
const
|
|
100
|
+
const mark = this.highWater.get(key) ?? { rev: 0, gen: 0 };
|
|
101
|
+
const floor = Math.max(prior?.rev ?? 0, mark.rev);
|
|
102
|
+
const gen = prior?.gen ?? mark.gen;
|
|
103
|
+
const next = { ...structuredClone(clean), rev: floor + 1, gen };
|
|
100
104
|
this.map.set(key, next);
|
|
105
|
+
this.highWater.set(key, { rev: next.rev, gen });
|
|
101
106
|
return structuredClone(next);
|
|
102
107
|
}
|
|
103
108
|
async listBySession(sessionId) {
|
|
@@ -110,10 +115,13 @@ export class InMemorySessionPolicyStore {
|
|
|
110
115
|
return out;
|
|
111
116
|
}
|
|
112
117
|
async deleteBySession(sessionId) {
|
|
113
|
-
for (const k of [...this.map.
|
|
118
|
+
for (const [k, row] of [...this.map.entries()]) {
|
|
114
119
|
const [sid] = JSON.parse(k);
|
|
115
|
-
if (sid
|
|
116
|
-
|
|
120
|
+
if (sid !== sessionId)
|
|
121
|
+
continue;
|
|
122
|
+
const mark = this.highWater.get(k) ?? { rev: 0, gen: 0 };
|
|
123
|
+
this.highWater.set(k, { rev: Math.max(mark.rev, row.rev), gen: mark.gen + 1 });
|
|
124
|
+
this.map.delete(k);
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
}
|
|
@@ -75,6 +75,16 @@ export async function backgroundAgentStoreContract(mk, runAssertion) {
|
|
|
75
75
|
const store = make();
|
|
76
76
|
await assert.rejects(store.put(record({ scope: "" })), "an empty scope must be refused loudly, not stored");
|
|
77
77
|
});
|
|
78
|
+
run("a row carrying a key this build does not declare round-trips: no refusal, every declared field intact", async (make) => {
|
|
79
|
+
const store = make();
|
|
80
|
+
await store.put({ ...record({ handle: "a-extra", name: "worker", agentType: "reader" }), teamName: "retired-column" });
|
|
81
|
+
const row = await held(store, "a-extra");
|
|
82
|
+
assert.equal(row.name, "worker");
|
|
83
|
+
assert.equal(row.agentType, "reader");
|
|
84
|
+
assert.equal(row.status, "running");
|
|
85
|
+
await store.update("a-extra", S, { ...row, status: "completed" }, { rev: row.rev });
|
|
86
|
+
assert.equal((await held(store, "a-extra")).status, "completed", "a CAS update over such a row still lands");
|
|
87
|
+
});
|
|
78
88
|
run("get returns DETACHED copies: mutating a read never reaches the stored row", async (make) => {
|
|
79
89
|
const store = make();
|
|
80
90
|
await store.put(record({ handle: "a1", usage: { totalTokens: 5 } }));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type PeerDirectory, type PeerSessionRecord } from "../../agents/peer-directory.js";
|
|
2
|
+
import { type ContractAssertionRunner } from "./contract-harness.js";
|
|
3
|
+
/** The scope every case registers rows under; the scope-wall case constructs a second one. */
|
|
4
|
+
export declare const PEER_DIRECTORY_CONTRACT_SCOPE = "peer-kit-A";
|
|
5
|
+
export interface PeerDirectoryContractHooks {
|
|
6
|
+
/** Write (or replace) one row through the host's own write protocol. The kit never writes a row
|
|
7
|
+
* the engine's reader would refuse. */
|
|
8
|
+
write: (dir: PeerDirectory, record: PeerSessionRecord) => void | Promise<void>;
|
|
9
|
+
runAssertion?: ContractAssertionRunner;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The directory contract. Four readings, each the reason the engine can do something:
|
|
13
|
+
* 1. **scope wall** — a listing is the ACCESSOR's scope only; the cross-principal wall's mechanical
|
|
14
|
+
* seat is this filter, so a row leaking across scopes is a tenant leak, not a display bug;
|
|
15
|
+
* 2. **the full set, copies** — live, dead AND deleted rows are all listed (a `name [ref]` is minted
|
|
16
|
+
* over the full set; a dead row is the name truth offline delivery resolves through; a deleted
|
|
17
|
+
* row resolves only to refuse), and a listed row is a COPY (mutating it changes no later listing);
|
|
18
|
+
* 3. **resolution feeds on the listing** — the engine's own resolver, over the rows as listed, finds
|
|
19
|
+
* a live row by name, still finds a dead one, and answers `deleted` for a tombstone;
|
|
20
|
+
* 4. **restore is CAS-on-absent** (only where the host implements it) — an absent row is restored and
|
|
21
|
+
* then listed; a present row is never overwritten (`false`, the existing row byte-unchanged).
|
|
22
|
+
*/
|
|
23
|
+
export declare function peerDirectoryContract(mk: () => PeerDirectory, hooks: PeerDirectoryContractHooks): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { strict as assert } from "node:assert";
|
|
2
|
+
import { resolvePeerSessions } from "../../agents/peer-directory.js";
|
|
3
|
+
import { beginContract } from "./contract-harness.js";
|
|
4
|
+
export const PEER_DIRECTORY_CONTRACT_SCOPE = "peer-kit-A";
|
|
5
|
+
function row(name, sessionId, over = {}) {
|
|
6
|
+
return { schemaVersion: 1, sessionId, scope: PEER_DIRECTORY_CONTRACT_SCOPE, name, startedAt: 1_000, updatedAt: 2_000, liveness: "live", ...over };
|
|
7
|
+
}
|
|
8
|
+
async function withDirs(mk, fn) {
|
|
9
|
+
const live = [];
|
|
10
|
+
const make = () => {
|
|
11
|
+
const d = mk();
|
|
12
|
+
live.push(d);
|
|
13
|
+
return d;
|
|
14
|
+
};
|
|
15
|
+
try {
|
|
16
|
+
await fn(make);
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
for (const d of live.splice(0))
|
|
20
|
+
d.close?.();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function peerDirectoryContract(mk, hooks) {
|
|
24
|
+
const { run: runRaw, settle } = beginContract(hooks.runAssertion);
|
|
25
|
+
const run = (name, fn) => runRaw(name, () => withDirs(mk, fn));
|
|
26
|
+
const S = PEER_DIRECTORY_CONTRACT_SCOPE;
|
|
27
|
+
run("scope wall: a listing is the accessor's scope only", async (make) => {
|
|
28
|
+
const d = make();
|
|
29
|
+
await hooks.write(d, row("alpha", "sess-a"));
|
|
30
|
+
await hooks.write(d, row("beta", "sess-b", { scope: "peer-kit-B" }));
|
|
31
|
+
const inA = await d.listPeerSessions({ scope: S });
|
|
32
|
+
assert.deepEqual(inA.map((r) => r.sessionId), ["sess-a"], "scope A must list exactly its own row");
|
|
33
|
+
const inB = await d.listPeerSessions({ scope: "peer-kit-B" });
|
|
34
|
+
assert.deepEqual(inB.map((r) => r.sessionId), ["sess-b"]);
|
|
35
|
+
const inC = await d.listPeerSessions({ scope: "peer-kit-C" });
|
|
36
|
+
assert.equal(inC.length, 0, "an unrelated scope sees nothing");
|
|
37
|
+
});
|
|
38
|
+
run("the full set is listed (live + dead + deleted) and every listed row is a copy", async (make) => {
|
|
39
|
+
const d = make();
|
|
40
|
+
await hooks.write(d, row("alpha", "sess-a"));
|
|
41
|
+
await hooks.write(d, row("beta", "sess-b", { liveness: "dead", diedAt: 3_000 }));
|
|
42
|
+
await hooks.write(d, row("gamma", "sess-c", { liveness: "deleted", diedAt: 3_000 }));
|
|
43
|
+
const listed = await d.listPeerSessions({ scope: S });
|
|
44
|
+
assert.deepEqual([...listed.map((r) => r.sessionId)].sort(), ["sess-a", "sess-b", "sess-c"], "dead and deleted rows are MARKED, never dropped from a listing");
|
|
45
|
+
assert.deepEqual(Object.fromEntries(listed.map((r) => [r.sessionId, r.liveness])), { "sess-a": "live", "sess-b": "dead", "sess-c": "deleted" });
|
|
46
|
+
const first = listed.find((r) => r.sessionId === "sess-a");
|
|
47
|
+
first.name = "scribbled";
|
|
48
|
+
const again = await d.listPeerSessions({ scope: S });
|
|
49
|
+
assert.equal(again.find((r) => r.sessionId === "sess-a").name, "alpha", "a listed row is a copy");
|
|
50
|
+
});
|
|
51
|
+
run("the engine's resolver over the listing: live by name, dead still found, deleted refuses", async (make) => {
|
|
52
|
+
const d = make();
|
|
53
|
+
await hooks.write(d, row("alpha", "sess-a"));
|
|
54
|
+
await hooks.write(d, row("beta", "sess-b", { liveness: "dead", diedAt: 3_000 }));
|
|
55
|
+
await hooks.write(d, row("gamma", "sess-c", { liveness: "deleted", diedAt: 3_000 }));
|
|
56
|
+
const rows = await d.listPeerSessions({ scope: S });
|
|
57
|
+
const alpha = resolvePeerSessions(rows, "alpha");
|
|
58
|
+
assert.equal(alpha.status, "found");
|
|
59
|
+
const beta = resolvePeerSessions(rows, "beta");
|
|
60
|
+
assert.equal(beta.status, "found", "a dead row is the name truth offline delivery resolves through");
|
|
61
|
+
const gamma = resolvePeerSessions(rows, "gamma");
|
|
62
|
+
assert.equal(gamma.status, "deleted", "a tombstone resolves only to refuse");
|
|
63
|
+
assert.equal(resolvePeerSessions(rows, "session.sess-b").status, "found", "an address resolves to a KNOWN row");
|
|
64
|
+
assert.equal(resolvePeerSessions(rows, "session.sess-zzz").status, "not_found", "an address nobody registered is not an append target");
|
|
65
|
+
});
|
|
66
|
+
run("restorePeerSession (where implemented) is CAS-on-absent: restores an absent row, never overwrites a present one", async (make) => {
|
|
67
|
+
const d = make();
|
|
68
|
+
if (d.restorePeerSession === undefined)
|
|
69
|
+
return;
|
|
70
|
+
const restored = await d.restorePeerSession(row("alpha", "sess-a", { liveness: "dead", diedAt: 3_000 }));
|
|
71
|
+
assert.equal(restored, true, "an absent row is restored");
|
|
72
|
+
assert.deepEqual((await d.listPeerSessions({ scope: S })).map((r) => r.sessionId), ["sess-a"]);
|
|
73
|
+
await hooks.write(d, row("alpha", "sess-a", { updatedAt: 9_000 }));
|
|
74
|
+
const again = await d.restorePeerSession(row("alpha-stale", "sess-a", { liveness: "dead", diedAt: 3_000 }));
|
|
75
|
+
assert.equal(again, false, "a present row wins over a restore");
|
|
76
|
+
const current = (await d.listPeerSessions({ scope: S })).find((r) => r.sessionId === "sess-a");
|
|
77
|
+
assert.equal(current.name, "alpha");
|
|
78
|
+
assert.equal(current.liveness, "live");
|
|
79
|
+
assert.equal(current.updatedAt, 9_000, "the existing row is byte-unchanged by a losing restore");
|
|
80
|
+
});
|
|
81
|
+
await settle();
|
|
82
|
+
}
|
|
@@ -883,6 +883,12 @@ export type TaskEvent = ({
|
|
|
883
883
|
* nor an agent-type — the child then keeps its taskId (NOT the raw objective, which could leak a delegated
|
|
884
884
|
* secret to a progress-scoped consumer — dual-review Q2). Sanitized (control-char-stripped, length-capped). */
|
|
885
885
|
name?: string;
|
|
886
|
+
/** The EXPLICIT `Agent({name})` spawn identity of the sub-run, when the spawn carried one — the
|
|
887
|
+
* teammate KEY, as opposed to {@link name} (the display label): the same string the fleet row's
|
|
888
|
+
* spawn frame (`BackgroundChildEvent.spawnName`), the roster row and the child's uplink `from`
|
|
889
|
+
* carry, so a consumer binds "@name → row" by string equality. Validated at spawn
|
|
890
|
+
* (`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`). ABSENT = an unnamed delegation, never a missing value. */
|
|
891
|
+
spawnName?: string;
|
|
886
892
|
/**
|
|
887
893
|
* The model this sub-run was PREPARED with — the resolved id, read off the leg's prepared model
|
|
888
894
|
* at the mint, so it is the same value `TaskResult.model` and the `task.start` trace frame carry.
|
|
@@ -1,7 +1,25 @@
|
|
|
1
|
+
import type { MailboxAppendMessage, MailboxStore } from "./mailbox-store.js";
|
|
1
2
|
/** design/135 G2: `"event"` is the ONE non-terminal member — a Monitor batch notification (new stdout
|
|
2
3
|
* lines from a still-running watch). Everything else is a terminal completion notification. */
|
|
3
4
|
export type TaskNotificationStatus = "completed" | "failed" | "killed" | "cancelled" | "event";
|
|
4
5
|
export type SystemInjectionPriority = "now" | "next" | "later";
|
|
6
|
+
/**
|
|
7
|
+
* The outcome an injection entry settles with — what the session did with the frame:
|
|
8
|
+
* · `queued` — a live lane took it (it rides that run's next turn boundary; accepted, not yet consumed);
|
|
9
|
+
* · `parked` — no live lane; pended IN PROCESS on {@link PendingSessionNotifications} for the
|
|
10
|
+
* session's next run in this process (a process restart loses it);
|
|
11
|
+
* · `parked_durably` — no live lane; appended to the session's DURABLE box
|
|
12
|
+
* ({@link PendingSessionNotifications.bindSessionBox}) — the next run drains it, in any process;
|
|
13
|
+
* · `dropped_duplicate` — the session already delivered this exact frame.
|
|
14
|
+
* The park backend is the seat's choice ({@link routeOrParkNotification}), never the producer's.
|
|
15
|
+
*/
|
|
16
|
+
export type NotificationInjectionOutcome = "queued" | "parked" | "parked_durably" | "dropped_duplicate";
|
|
17
|
+
export declare const NOTIFICATION_INJECTION_OUTCOMES: readonly ["queued", "parked", "parked_durably", "dropped_duplicate"];
|
|
18
|
+
/** Is `value` one of the injector's outcome words? A host-wired uplink may answer anything (a bare
|
|
19
|
+
* `void`, an array's `push` count) — such an answer reads as "outcome not reported", never as a word. */
|
|
20
|
+
export declare function isNotificationInjectionOutcome(value: unknown): value is NotificationInjectionOutcome;
|
|
21
|
+
/** The disposition a park/route decision reports back — the injection outcome minus the dedup fold. */
|
|
22
|
+
export type NotificationDisposition = Exclude<NotificationInjectionOutcome, "dropped_duplicate">;
|
|
5
23
|
/** The closed value set of {@link SystemInjectionPriority}, as data — the wire-facing entries validate
|
|
6
24
|
* against THIS rather than against three inline string literals, so widening the type widens the guard.
|
|
7
25
|
* (Names taken verbatim from CC 2.1.223's `UYo` @ `CC:644918`; see the semantics note on
|
|
@@ -135,6 +153,12 @@ export interface TaskNotificationPayload {
|
|
|
135
153
|
* outside caller can neither mint a chain nor wear the peer class. */
|
|
136
154
|
peer?: {
|
|
137
155
|
hopChain: string[];
|
|
156
|
+
/** The SENDING run's session id, when the producer knows it — the reply/receipt address a durable
|
|
157
|
+
* park of this frame records (`MailboxPeerMeta.fromSession`). Engine-read; never rendered. */
|
|
158
|
+
fromSession?: string;
|
|
159
|
+
/** The sender's admission key (the same token the send-point gate charged), so a durable park
|
|
160
|
+
* re-judges the frame under the same sender at the drain. Engine-read; never rendered. */
|
|
161
|
+
senderKey?: string;
|
|
138
162
|
};
|
|
139
163
|
/**
|
|
140
164
|
* design/385 §1.4 d1 — the delegated-child → parent UPLINK carrier (`SendMessage("main")`). Its
|
|
@@ -264,7 +288,7 @@ export interface SystemInjection<TPayload = unknown> {
|
|
|
264
288
|
* listener: "queued" once the boundary steer accepted, "parked" when the idle tail-race (or the
|
|
265
289
|
* engine-note backlog cap) rejected and the payload was pended per session. Absent =
|
|
266
290
|
* fire-and-forget producer (no receipt to honor). */
|
|
267
|
-
onDisposition?: (d:
|
|
291
|
+
onDisposition?: (d: NotificationDisposition) => void;
|
|
268
292
|
}
|
|
269
293
|
/** design/373 (adversarial r3, recorded rule): the key deliberately EXCLUDES the injection tier.
|
|
270
294
|
* The key names an EVENT's identity; `priority` is delivery metadata about one submission of it.
|
|
@@ -356,6 +380,27 @@ export declare function renderTaskNotificationXml(n: TaskNotificationPayload): s
|
|
|
356
380
|
export declare const MAX_PENDING_EVENTS_PER_TASK = 50;
|
|
357
381
|
export declare const MAX_PENDING_EVENTS_PER_SESSION = 200;
|
|
358
382
|
export declare const MAX_PENDING_SESSIONS = 100;
|
|
383
|
+
/** Session-box bindings are three references and a handle; bounded far above the parked-session cap so
|
|
384
|
+
* a long-lived leader's binding is not evicted by parked traffic (eviction = the park backend degrades
|
|
385
|
+
* to in-process, which the receipt then says). */
|
|
386
|
+
export declare const MAX_SESSION_BOX_BINDINGS = 4096;
|
|
387
|
+
/**
|
|
388
|
+
* A session's DURABLE box, bound to the park seat by the leg that mounts its session-box drain — one
|
|
389
|
+
* mint (the drain mount judgment), two readers (the drain and {@link routeOrParkNotification}), so
|
|
390
|
+
* "a frame parked here is drained there" holds by construction. Absent for a session whose legs mount
|
|
391
|
+
* no drain (a delegated child, an ungrammatical id, no cross-process-safe mailbox).
|
|
392
|
+
*/
|
|
393
|
+
export interface SessionBoxBinding {
|
|
394
|
+
mailbox: MailboxStore;
|
|
395
|
+
/** The registry/mailbox scope the session's legs mount in (the drain reads the same scope). */
|
|
396
|
+
scope: string;
|
|
397
|
+
/** The box handle (`session.<id>`), minted after the address grammar passed. */
|
|
398
|
+
handle: string;
|
|
399
|
+
/** The session id the box belongs to — the `fromRoot` a durably parked child frame records. */
|
|
400
|
+
sessionId: string;
|
|
401
|
+
/** Where an append fault is disclosed (the frame then takes the in-process park — never lost). */
|
|
402
|
+
onError?: (e: unknown) => void;
|
|
403
|
+
}
|
|
359
404
|
/**
|
|
360
405
|
* RB-375 A2 (ruled 2026-08-03) — is this frame the TERMINAL of a delegated background agent?
|
|
361
406
|
*
|
|
@@ -423,7 +468,7 @@ export interface DrainedPendingNotifications {
|
|
|
423
468
|
* · `"unavailable"` — the lane can no longer take anything; the caller tries the next lane, and
|
|
424
469
|
* finally the parked store.
|
|
425
470
|
*/
|
|
426
|
-
export type LiveNotificationLane = (payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d:
|
|
471
|
+
export type LiveNotificationLane = (payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d: NotificationDisposition) => void) => "taken" | "pending" | "duplicate" | "unavailable";
|
|
427
472
|
/** One run's seat: its two entry points, plus the two bookkeeping verbs its owner drives. */
|
|
428
473
|
export interface LiveNotificationLaneSeat {
|
|
429
474
|
/**
|
|
@@ -500,7 +545,7 @@ export declare class SessionLiveNotificationLanes {
|
|
|
500
545
|
* special-cased: a lane that just refused this frame reports `"unavailable"` for it from then on.
|
|
501
546
|
*/
|
|
502
547
|
deliver(sessionId: string, payload: TaskNotificationPayload, priority: SystemInjectionPriority, opts?: {
|
|
503
|
-
onDisposition?: (d:
|
|
548
|
+
onDisposition?: (d: NotificationDisposition) => void;
|
|
504
549
|
}): "taken" | "pending" | "duplicate" | "unavailable";
|
|
505
550
|
/** Sessions holding at least one live lane (observability + the tests' rollback detector). */
|
|
506
551
|
get size(): number;
|
|
@@ -528,6 +573,16 @@ export declare class PendingSessionNotifications {
|
|
|
528
573
|
/** Whole-session losses whose tombstone was itself evicted — the count survives, the attribution
|
|
529
574
|
* does not. Surfaced on the next session-level disclosure so it is never simply forgotten. */
|
|
530
575
|
private unattributedDrops;
|
|
576
|
+
/** The durable park backend per session ({@link SessionBoxBinding}); insertion-ordered, bounded. */
|
|
577
|
+
private readonly sessionBoxes;
|
|
578
|
+
/**
|
|
579
|
+
* Bind (or, with `undefined`, unbind) the session's durable box. Written at EVERY drain mount of a
|
|
580
|
+
* session's leg, so the binding tracks the deployment's current seats (a mailbox swapped out
|
|
581
|
+
* unbinds at the next mount). A re-bind refreshes the entry's LRU position.
|
|
582
|
+
*/
|
|
583
|
+
bindSessionBox(sessionId: string, box: SessionBoxBinding | undefined): void;
|
|
584
|
+
/** The session's durable box, when a leg of it mounted the drain in this process. */
|
|
585
|
+
sessionBoxFor(sessionId: string): SessionBoxBinding | undefined;
|
|
531
586
|
pend(sessionId: string, n: TaskNotificationPayload, priority?: SystemInjectionPriority): void;
|
|
532
587
|
/** Remove and return the session's pendings (one-shot — the next run consumes them exactly once).
|
|
533
588
|
* design/373 §3.5: the batch comes out PRIORITY-MAJOR (stable sort — pend order within a class;
|
|
@@ -548,6 +603,13 @@ export declare class PendingSessionNotifications {
|
|
|
548
603
|
* next drain. Callers ask BEFORE routing and BEFORE parking.
|
|
549
604
|
*/
|
|
550
605
|
holdsKey(sessionId: string, dedupKey: string): boolean;
|
|
606
|
+
/** Keys the session's DURABLE box took ({@link routeOrParkNotification}'s durable arm) — the third holder
|
|
607
|
+
* {@link holdsKey} consults, so a retry of a frame the box already holds folds exactly as one the
|
|
608
|
+
* in-process store holds. Bounded like {@link routed}; a key is released when the append faulted and
|
|
609
|
+
* the frame took the in-process park instead (that store then owns it). */
|
|
610
|
+
private readonly durable;
|
|
611
|
+
noteDurablePark(sessionId: string, dedupKey: string): void;
|
|
612
|
+
forgetDurablePark(sessionId: string, dedupKey: string): void;
|
|
551
613
|
/** Record that this session has DELIVERED this frame — see {@link routed}. Written at ONE place, the
|
|
552
614
|
* harness's consumption sink (the step where a queued frame enters the model's input), so the record
|
|
553
615
|
* can never run ahead of the delivery it claims. */
|
|
@@ -565,7 +627,20 @@ export declare class PendingSessionNotifications {
|
|
|
565
627
|
* ({@link LiveNotificationLaneSeat.markDelivered});
|
|
566
628
|
* · nothing can take it ⇒ park, carrying the tier, for the session's next run.
|
|
567
629
|
*/
|
|
568
|
-
export declare function routeOrParkNotification(store: PendingSessionNotifications, sessionId: string, payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d:
|
|
630
|
+
export declare function routeOrParkNotification(store: PendingSessionNotifications, sessionId: string, payload: TaskNotificationPayload, priority: SystemInjectionPriority, onDisposition?: (d: NotificationDisposition) => void): void;
|
|
631
|
+
/**
|
|
632
|
+
* The durable form of a parked peer-words frame: the SAME `peer_message` record the cross-session lane
|
|
633
|
+
* appends (the recipient's drain renders it through the one envelope and judges it through the one
|
|
634
|
+
* admission gate), with `fromRoot` = the box owner's own session — a frame reaches this seat only
|
|
635
|
+
* through the injector its owner handed to a child of its tree, so the same-tree fact is the seat's to
|
|
636
|
+
* state, not the producer's to claim.
|
|
637
|
+
*/
|
|
638
|
+
export declare function sessionBoxRecordOf(payload: TaskNotificationPayload & {
|
|
639
|
+
agentMessage: {
|
|
640
|
+
from: string;
|
|
641
|
+
body: string;
|
|
642
|
+
};
|
|
643
|
+
}, box: Pick<SessionBoxBinding, "sessionId">): MailboxAppendMessage;
|
|
569
644
|
/**
|
|
570
645
|
* Delivery-side overflow disclosure: fold the per-task drop counts into the drained payloads. Two
|
|
571
646
|
* carrier shapes, chosen per survivor:
|