@sema-agent/core 7.3.0 → 7.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/dist/agents/peer-admission.d.ts +18 -3
- package/dist/agents/peer-admission.js +79 -4
- package/dist/agents/peer-held-queue.d.ts +101 -0
- package/dist/agents/peer-held-queue.js +229 -0
- package/dist/agents/peer-idle.d.ts +109 -0
- package/dist/agents/peer-idle.js +240 -0
- package/dist/agents/peer-notice-route.d.ts +33 -0
- package/dist/agents/peer-notice-route.js +46 -0
- package/dist/agents/peer-notices.d.ts +103 -0
- package/dist/agents/peer-notices.js +206 -0
- package/dist/agents/peer-session-drain.d.ts +39 -4
- package/dist/agents/peer-session-drain.js +248 -42
- package/dist/agents/send-message-tool.d.ts +8 -1
- package/dist/agents/send-message-tool.js +96 -30
- package/dist/agents/subagent.js +1 -0
- package/dist/brain/status-sink.d.ts +10 -0
- package/dist/brain/status-sink.js +13 -4
- package/dist/brain/stream-engine.d.ts +11 -0
- package/dist/brain/stream-engine.js +39 -3
- package/dist/core/arg-summary.d.ts +13 -3
- package/dist/core/arg-summary.js +138 -7
- package/dist/core/auto-mode-defaults.d.ts +11 -0
- package/dist/core/auto-mode-defaults.js +2 -0
- package/dist/core/auto-mode.d.ts +59 -0
- package/dist/core/auto-mode.js +57 -1
- package/dist/core/checkpoint-store.js +2 -2
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +8 -0
- package/dist/core/hooks.d.ts +30 -0
- package/dist/core/hooks.js +43 -8
- package/dist/core/mailbox-store.d.ts +33 -1
- package/dist/core/mailbox-store.js +42 -2
- package/dist/core/runner/assemble-result.d.ts +5 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/denial-limit-arms.d.ts +149 -0
- package/dist/core/runner/denial-limit-arms.js +91 -0
- package/dist/core/runner/edited-files-ledger.d.ts +33 -0
- package/dist/core/runner/edited-files-ledger.js +14 -0
- package/dist/core/runner/prepare-hands-readface.d.ts +5 -0
- package/dist/core/runner/prepare-hands-readface.js +1 -0
- package/dist/core/runner/prepare-task.d.ts +62 -1
- package/dist/core/runner/prepare-task.js +135 -89
- package/dist/core/runner/runtask.js +12 -0
- package/dist/core/sensitive-path-policy.d.ts +27 -6
- package/dist/core/sensitive-path-policy.js +57 -2
- package/dist/core/task-notification.d.ts +24 -2
- package/dist/core/task-notification.js +6 -1
- package/dist/core/tool-policy.d.ts +55 -4
- package/dist/core/tool-policy.js +28 -5
- package/dist/core/tools.js +1 -0
- package/dist/core/types.d.ts +251 -15
- package/dist/core/wiring-manifest.d.ts +41 -5
- package/dist/core/wiring-manifest.js +8 -0
- package/dist/engine/harness/agent-harness.d.ts +1 -0
- package/dist/engine/harness/agent-harness.js +3 -0
- package/dist/engine/harness/types.d.ts +3 -0
- package/dist/engine/loop/agent-loop.d.ts +7 -0
- package/dist/engine/loop/agent-loop.js +79 -0
- package/dist/engine/loop/types.d.ts +42 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +10 -4
- package/dist/internal/harness-types.d.ts +1 -1
- package/dist/orchestration/workflow.js +7 -3
- package/dist/tools/fs/fs-write.d.ts +4 -4
- package/dist/tools/fs/fs-write.js +99 -14
- package/dist/tools/fs/index.d.ts +7 -1
- package/dist/tools/fs/index.js +1 -1
- package/dist/tools/fs/safety.d.ts +29 -8
- package/dist/tools/fs/safety.js +11 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +181 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 7.4.0 — 2026-09-04
|
|
4
|
+
|
|
5
|
+
Bug-fix release. No exports removed, no wire key removed; every narrowing is named below.
|
|
6
|
+
|
|
7
|
+
### Fixed — #548 auto-mode classifier denial limit escalates to a person (CC 2.1.250 `FO`/`Wie`/`eme`/`yR`/`tme`)
|
|
8
|
+
- `createAutoModeDenialTracker`: per run, count-then-judge (`consecutive >= 3 || total >= 20`, defaults `AUTO_MODE_DENIAL_LIMIT_DEFAULTS`); reset on a classifier allow or a person's allow (CC `yR` call sites), not on tool success. Knob `RunnerDeps.autoMode.denialLimit { maxConsecutive, maxTotal, autoDenyAfterMs }` — bad values throw at prepare (loud).
|
|
9
|
+
- The block that trips the limit is minted as an **ask** (`decisionReason: "classifier"`) carrying the additive member `denialLimitFallback { consecutive, total, limit, autoDenyAfterMs }` and `requiresRealApproval: true`; the sandbox-admission, inherited-wrapper and blanket-`onAsk` lanes cannot consume it (pinned per lane). A non-tripping block stays a byte-identical deny.
|
|
10
|
+
- **Narrowing, named**: the CONSECUTIVE-bound fallback ask auto-denies after 120 s with no answer (`ResolvedAsk.autoDenied` / `PermissionDeniedPayload.autoDenied`, additive; CC's timed shape); the TOTAL-bound card is never timed (CC form: it waits for a person). `autoDenyAfterMs: 0` disarms the window. Only fallback asks are timed; ordinary asks arm no timer. Parked (durable) fallback asks are still timed in this version — #559.
|
|
11
|
+
- **Narrowing, named**: headless (no ask channel) — the third consecutive block stops the run with `TaskResult.errorCode = "classifier.denial_limit"` (new terminal member) plus a same-code notice; previously the run kept being denied without limit. `ENGINE_NOTICE_CODES` + `classifier.denial_limit` (audience `user`).
|
|
12
|
+
- Not carried: the persisted arming recipe does not carry the denial-limit bounds (#556); the durable park record does not carry `denialLimitFallback` (#557, server/cli seam).
|
|
13
|
+
|
|
14
|
+
### Fixed — #549 the sensitive-path write guard knew one spelling of the agent's own config root
|
|
15
|
+
- `RECOMMENDED_SENSITIVE_PATTERNS` + `.sema`, `.sema.*`, `.sema-notifier-build` (three exact entries, not a namespace glob — `.semaphore/`, `.semantic/` and the engine's own `.sema-worktrees/` parent are untouched; a `.sema-*` family entry was tried and withdrawn before release because it hard-denied every fs-hand write inside an isolated worktree); the shell's `~/.sema/settings.json` / `~/.sema.json` are now guarded like their `.claude*` twin, closing a self-authorization write through Write/Edit.
|
|
16
|
+
- **Data-root passthrough (new, load-bearing)**: `createSensitivePathPolicy` gains `dataRoot?: string` (default `$AGENT_DATA_DIR ?? ~/.ai-agent`, same caliber as `createTranscriptIntegrityPolicy`). A target inside the engine data root is judged on its sub-path only — the root's own ancestors (e.g. `.sema` when the shell sites the root at `~/.sema/engine-data`) never deny it — while a sensitive segment inside the root still denies. Root compared in two views (canonical via the same `canonicalizeTarget`, lexical fallback), every view anchored. Without this the memory library the engine tells the model to Write into would have been hard-denied on the shell deployment.
|
|
17
|
+
- `PATH_NOT_IN_ROOT_ESCAPE_HINT` drops its closing sentence naming Bash as unconfined by the fence (a live retest showed the model taking it as the next step); the card offers the sanctioned widening only.
|
|
18
|
+
- `expandHomeTilde` now lives in `src/tools/fs/safety.ts` (path-family home).
|
|
19
|
+
|
|
20
|
+
### Fixed — #551 peer-admission out-of-range values fell back silently
|
|
21
|
+
- A `peerAdmission` field outside its window (or not a finite number) still falls back to that field's default, and now announces `config.peer_admission_out_of_range` (audience `operator`; `detail { field, given, default, range: [lo, hi], reason: "out_of_range" | "not_a_finite_number" }`) once per field per structurally distinct configuration. `ENGINE_NOTICE_CODES` + 1.
|
|
22
|
+
|
|
23
|
+
### Fixed — #535 a delegated child's file edits belong to the ROOT session's file history
|
|
24
|
+
- The root run exposes a file-history lineage `{ scope, root, fs }` on `ToolExecuteContext.fileHistoryLineage`; the Agent tool threads it verbatim into every fresh spawn leg (`RunInternals.fileHistoryLineage`, never the revive arm). A child whose canonical root and filesystem identity equal the lineage's records its first-touch edits (and the #491 retraction) into the root scope, mints no boundary of its own, and skips the fork carry; fixed point at any depth. A child on another tree (worktree isolation, explicit cwd, unattested env) keeps its own scope.
|
|
25
|
+
- **Narrowing, named**: a restore request (`rewindFilesTo` / `resumeAt + restoreFiles`) on a child recording into its root scope is refused with prepare-throw code `rewind.child_scope_unsupported` (previously converged silently on an empty child scope). A delegated fork on another tree starts empty instead of carrying the source scope.
|
|
26
|
+
- Not carried: revive/`resume(token)` legs (checkpoint has no lineage seat), workflow/team spawn legs, root-vs-background-child restore concurrency — listed on #535.
|
|
27
|
+
|
|
28
|
+
### Also in this version (landed earlier on main, first published here)
|
|
29
|
+
- design/385 slice 4 (cross-session messaging, held queue): the cross-session NOTICE vocabulary and its store/carrier seats; the HELD QUEUE machine (a peer `hold` becomes held-queue-and-ACK instead of park-and-block, `held_settled` outcome, review deadline follows the cause, idle-distance cap, unavailable ⇒ roll back the newest); the IDLE SUBSCRIPTION machine and SendMessage `notify_when_idle` (declared in the schema only when the lane is mounted, CC form); `fromSession` grammar guard; receiving-round fixes (hold dedup, approve-throw keeps the entry held, released entries never re-arm the deadline). Wire: `docs/sdk/07-multi-agent.md` §Slice 4. Server S-109 window opens here.
|
|
30
|
+
- #533 workflow journal writes strip `errorMessage` on non-completed rows; replay re-issues only completed rows.
|
|
31
|
+
- #534 `TaskResult.editedFiles` (additive): canonical paths first-touched by fs write tools, three-valued law (landed / ambiguous / thrown counted; proven-unwritten not), cap 1000.
|
|
32
|
+
- #550 checkpoint risk descriptor: scan-then-strip order (`stripFormatCharacters(redactSecrets(x))`) at both summary arms.
|
|
33
|
+
- Consumer smoke probe registers the 385 slice-2b/4 peer-family exports (45 entries).
|
|
34
|
+
|
|
35
|
+
### Export surface
|
|
36
|
+
- +9 exports (#548 tracker/knob/message family), +0 removed; `test/export-surface.snapshot.json` 2039 → 2048.
|
|
37
|
+
|
|
38
|
+
## 7.3.1 — 2026-09-03
|
|
39
|
+
|
|
40
|
+
### Changed (narrowing, named) — #531 Edit validates BEFORE it asks
|
|
41
|
+
- **`ToolSpec.validateInput?(args, ctx)`** — a pure input pre-validation hook the loop runs BEFORE the permission gate (CC parity: `validateInput` precedes the permission prompt; a refusal is a typed tool result, not a card). Edit / Write-on-existing / NotebookEdit implement it with the same `requireRead` + path checks the execute path keeps (double-checked; TOCTOU-safe). **Narrowing**: an Edit of a file the model never Read no longer raises an approval card — the model receives the read-first refusal directly (the auto classifier is not consulted); a Read-then-Edit still asks exactly as before. A throwing validator reads as "no verdict" (the call proceeds to the gate; one `onError` hook-phase disclosure).
|
|
42
|
+
|
|
43
|
+
### Added — #530 `waiting_first_token` status phase (closed set 6 → 7)
|
|
44
|
+
- `BrainStatusPhase` +`"waiting_first_token"`: with an armed first-token watchdog (`firstTokenTimeoutMs` > 30s), a `status` frame is emitted once the first token has been awaited 30s and every 30s after (`elapsedMs`, `timeoutMs`; no `retryAtMs`); cleared by the token, the watchdog or the call's end. An announced wait closes with the terminal frame (`recovered`, detail `first token arrived`) — never a retry's wording. Constants `WAITING_FIRST_TOKEN_AFTER_MS` / `WAITING_FIRST_TOKEN_EVERY_MS` exported. **Consumers pinning the six-member phase set must widen** (cli client-core retry-status closed set; server status projection is a pass-through).
|
|
45
|
+
|
|
46
|
+
### Added — #529 `WiringManifest.autoMode { armed, reason }`
|
|
47
|
+
- Minted on every leg (effective half; fingerprinted like `modelGate`): `reason ∈ armed | no_intent | no_face | denied | resolver_fault | latch_open` (`AUTO_MODE_ARM_REASONS`), `armed ⇔ reason === "armed"`. The per-leg READ face of the #521 arming decision.
|
|
48
|
+
|
|
49
|
+
### Fixed — #532 format characters inside / around a secret (family fix of A-087 root C)
|
|
50
|
+
- The format-free scanning view sinks into `runRedactionPasses`, so BOTH entries (`scrubSecrets`, `redactSecrets`) and every consumer (`primaryActivityArg` → `TaskResult.stats.humanReview.gates[].toolArg`, previews, error text) redact a key split by U+200B / U+00AD / U+FEFF as one marker; the raw scan and one suffix scan per format slot are unioned in so a format character used as the BOUNDARY before a key (`x<ZWSP>sk-…`) is still found; past 64 format slots the input is replaced whole (over-strip, never a leak). Format characters outside a secret are preserved; format-free input is byte-identical.
|
|
51
|
+
|
|
3
52
|
## 7.3.0 — 2026-09-03
|
|
4
53
|
|
|
5
54
|
### Added — design/385 slice 2a + 2b (cross-session peer messaging: mailbox contract, file backend, agents domain; +40 exports, 0 removed)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type EngineNotice } from "../core/types.js";
|
|
1
2
|
/** Hop-chain axis tags: durable a* handle / sessionId / taskId. Order IS the key-preference order. */
|
|
2
3
|
export type PeerAxisTag = "h" | "s" | "t";
|
|
3
4
|
/** The five-code closed refusal set (`details.error` values). The first four are gate verdicts;
|
|
@@ -29,9 +30,23 @@ export interface PeerAdmissionConfig {
|
|
|
29
30
|
}
|
|
30
31
|
/** Upstream defaults, adopted verbatim (anchored; deviation would need evidence sema does not have). */
|
|
31
32
|
export declare const PEER_ADMISSION_DEFAULTS: Readonly<PeerAdmissionConfig>;
|
|
32
|
-
/**
|
|
33
|
-
*
|
|
34
|
-
export declare function
|
|
33
|
+
/** Test seam (mirrors `__resetMaterializeEnvAnnouncements`); never called by production code.
|
|
34
|
+
* Re-arms BOTH arms (a WeakMap has no clear — it is re-minted). */
|
|
35
|
+
export declare function __resetPeerAdmissionRangeAnnouncements(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a caller's partial config against the defaults, per-field range-clamped-to-default.
|
|
38
|
+
* There is no off switch: the guard is always on; the ranges ARE the host's tuning space.
|
|
39
|
+
*
|
|
40
|
+
* A field the resolver cannot use falls back to ITS default (never a clamp to the nearest edge, and
|
|
41
|
+
* never a whole-config reject) — and, since #551, says so: one `config.peer_admission_out_of_range`
|
|
42
|
+
* notice PER FIELD carrying `{ field, given, default, range, reason }`. Both silent arms are covered:
|
|
43
|
+
* a value outside the legal window, and a value that is not a finite number at all (a string off an
|
|
44
|
+
* untyped host's JSON wiring, `NaN`, `Infinity`). Two bad fields in one call are two notices — the
|
|
45
|
+
* operator has two things to fix and folding them into one line would hide the second. `onNotice` is
|
|
46
|
+
* the deployment's sink; absent, the announcement still reaches `console.warn` through the shared
|
|
47
|
+
* delivery throat (an unwired host is not a silent one).
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolvePeerAdmissionConfig(overrides?: Partial<PeerAdmissionConfig>, onNotice?: (notice: EngineNotice) => void): PeerAdmissionConfig;
|
|
35
50
|
/** Hop-chain carrier sliding window: a chain keeps its most recent entries (upstream `Xbr`=32).
|
|
36
51
|
* The chain grows ONLY at send time (the sender appends its own token); delivery/replay never
|
|
37
52
|
* appends — a message-driven revival is not a hop. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { deliverEngineNotice } from "../core/types.js";
|
|
2
3
|
export const PEER_ADMISSION_DEFAULTS = Object.freeze({
|
|
3
4
|
bucketCapacity: 30,
|
|
4
5
|
refillPerSecond: 0.5,
|
|
@@ -19,21 +20,95 @@ const CONFIG_RANGES = Object.freeze({
|
|
|
19
20
|
maxQueuedPeerMessages: [10, 5_000],
|
|
20
21
|
maxTrackedRecipients: [16, 100_000],
|
|
21
22
|
});
|
|
22
|
-
|
|
23
|
+
let announcedOutOfRangeBySink = new WeakMap();
|
|
24
|
+
const announcedOutOfRangeConsole = new Set();
|
|
25
|
+
function peerAdmissionRangeLedger(onNotice) {
|
|
26
|
+
if (typeof onNotice !== "function")
|
|
27
|
+
return announcedOutOfRangeConsole;
|
|
28
|
+
let lines = announcedOutOfRangeBySink.get(onNotice);
|
|
29
|
+
if (lines === undefined) {
|
|
30
|
+
lines = new Set();
|
|
31
|
+
announcedOutOfRangeBySink.set(onNotice, lines);
|
|
32
|
+
}
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
export function __resetPeerAdmissionRangeAnnouncements() {
|
|
36
|
+
announcedOutOfRangeBySink = new WeakMap();
|
|
37
|
+
announcedOutOfRangeConsole.clear();
|
|
38
|
+
}
|
|
39
|
+
export function resolvePeerAdmissionConfig(overrides, onNotice) {
|
|
23
40
|
const out = { ...PEER_ADMISSION_DEFAULTS };
|
|
24
41
|
if (overrides === undefined)
|
|
25
42
|
return out;
|
|
26
43
|
for (const k of Object.keys(CONFIG_RANGES)) {
|
|
27
44
|
const v = overrides[k];
|
|
28
|
-
if (
|
|
45
|
+
if (v === undefined)
|
|
29
46
|
continue;
|
|
30
47
|
const [lo, hi] = CONFIG_RANGES[k];
|
|
31
|
-
|
|
48
|
+
const reason = typeof v !== "number" || !Number.isFinite(v)
|
|
49
|
+
? "not_a_finite_number"
|
|
50
|
+
: v < lo || v > hi
|
|
51
|
+
? "out_of_range"
|
|
52
|
+
: undefined;
|
|
53
|
+
if (reason === undefined) {
|
|
54
|
+
out[k] = v;
|
|
32
55
|
continue;
|
|
33
|
-
|
|
56
|
+
}
|
|
57
|
+
announcePeerAdmissionFallback(k, v, reason, lo, hi, onNotice);
|
|
34
58
|
}
|
|
35
59
|
return out;
|
|
36
60
|
}
|
|
61
|
+
function renderConfigValue(v) {
|
|
62
|
+
try {
|
|
63
|
+
return String(v);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return `[unrenderable ${typeof v}]`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function nonFiniteMarker(marks) {
|
|
70
|
+
let ordinal = 0;
|
|
71
|
+
return (_key, v) => {
|
|
72
|
+
ordinal += 1;
|
|
73
|
+
const n = typeof v === "number" ? v : Object.prototype.toString.call(v) === "[object Number]" ? Number(v) : undefined;
|
|
74
|
+
if (n !== undefined && !Number.isFinite(n))
|
|
75
|
+
marks.push(`${ordinal}:${String(n)}`);
|
|
76
|
+
return v;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function configValueKey(v) {
|
|
80
|
+
try {
|
|
81
|
+
const marks = [];
|
|
82
|
+
const j = JSON.stringify(v, nonFiniteMarker(marks));
|
|
83
|
+
if (j !== undefined)
|
|
84
|
+
return JSON.stringify([j, marks]);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
}
|
|
88
|
+
return renderConfigValue(v);
|
|
89
|
+
}
|
|
90
|
+
function announcePeerAdmissionFallback(field, given, reason, lo, hi, onNotice) {
|
|
91
|
+
const fallback = PEER_ADMISSION_DEFAULTS[field];
|
|
92
|
+
const shown = renderConfigValue(given);
|
|
93
|
+
const key = JSON.stringify([field, typeof given, configValueKey(given)]);
|
|
94
|
+
const ledger = peerAdmissionRangeLedger(onNotice);
|
|
95
|
+
if (ledger.has(key))
|
|
96
|
+
return;
|
|
97
|
+
ledger.add(key);
|
|
98
|
+
deliverEngineNotice(onNotice, {
|
|
99
|
+
code: "config.peer_admission_out_of_range",
|
|
100
|
+
message: `Peer-admission setting ${field}=${shown} is ` +
|
|
101
|
+
(reason === "out_of_range" ? `outside its legal range [${lo}, ${hi}]` : `not a finite number`) +
|
|
102
|
+
`; that field falls back to its default (${fallback}). Every other field is unaffected.`,
|
|
103
|
+
detail: {
|
|
104
|
+
field,
|
|
105
|
+
given: typeof given === "number" || typeof given === "string" || typeof given === "boolean" ? given : shown,
|
|
106
|
+
default: fallback,
|
|
107
|
+
range: [lo, hi],
|
|
108
|
+
reason,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
37
112
|
export const PEER_HOP_CHAIN_WINDOW = 32;
|
|
38
113
|
export function peerAxisToken(scope, axis, value) {
|
|
39
114
|
return JSON.stringify([scope ?? "", axis, value]);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { MailboxPeerMeta } from "../core/mailbox-store.js";
|
|
2
|
+
import type { CrossSessionHoldCause, CrossSessionInboundVerdict } from "./cross-session-judge.js";
|
|
3
|
+
import { type PeerDeliveryReceiptState } from "./peer-notices.js";
|
|
4
|
+
/** The `cause` vocabulary of a `peer.inbound_disposition{disposition:"held"}` notice: the parity
|
|
5
|
+
* judge's causes plus the drain-stage gate's TRANSIENT `rate_limited` (a bucket-wait, not a review
|
|
6
|
+
* hold — it never enters the held queue; the drain stops at that seq and re-judges next round). */
|
|
7
|
+
export type PeerInboundHoldCause = CrossSessionHoldCause | "rate_limited";
|
|
8
|
+
export interface PeerHeldEntry {
|
|
9
|
+
/** The recipient box seq of the held record (its identity for the review face). */
|
|
10
|
+
readonly seq: number;
|
|
11
|
+
/** The recipient's box handle. */
|
|
12
|
+
readonly box: string;
|
|
13
|
+
/** The stored body (`[summary] message`, the canonical stored form). */
|
|
14
|
+
readonly content: string;
|
|
15
|
+
/** The sender's display name as the record carried it (display only). */
|
|
16
|
+
readonly from?: string;
|
|
17
|
+
readonly sentAt: number;
|
|
18
|
+
readonly hopChain?: readonly string[];
|
|
19
|
+
/** The typed record — reply routing (`fromSession`) and the attestation the re-judgment reads. */
|
|
20
|
+
readonly peerMeta?: MailboxPeerMeta;
|
|
21
|
+
readonly heldAt: number;
|
|
22
|
+
/** The cause last announced for this entry (re-judgment may move it). */
|
|
23
|
+
cause: CrossSessionHoldCause;
|
|
24
|
+
/** Approved by the review face while no run of the session was live: delivered at the next flush
|
|
25
|
+
* (a re-judgment that now refuses still wins — CC `l0e`'s approve arm re-checks policy). */
|
|
26
|
+
released?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export type PeerHeldSettlement = "expired" | "delivered" | "refused" | "denied" | "dropped";
|
|
29
|
+
export type PeerHeldSettleReason = "evicted" | "deadline" | "shutdown" | "rejudge" | "approved" | "denied" | "cancelled" | "inject_failed";
|
|
30
|
+
/** The clock/timer seam (tests inject a virtual one). Handles are opaque. */
|
|
31
|
+
export interface PeerClock {
|
|
32
|
+
now(): number;
|
|
33
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
34
|
+
clearTimeout(handle: unknown): void;
|
|
35
|
+
}
|
|
36
|
+
export declare const realPeerClock: PeerClock;
|
|
37
|
+
/** The live seats a bound run gives the queue. `inject` is the ONLY member that is run-scoped (a run's
|
|
38
|
+
* own injection lane); the others survive the run (the receipt route rides the mailbox, the judge
|
|
39
|
+
* reads the deployment's settings getter) and are what a headless deadline settles through. */
|
|
40
|
+
export interface PeerHeldQueueSink {
|
|
41
|
+
/** Re-judge an entry against the recipient's CURRENT settings and mode class (CC `M`). */
|
|
42
|
+
judge: (entry: PeerHeldEntry) => CrossSessionInboundVerdict;
|
|
43
|
+
/** Deliver a released entry into the live run's model context. Absent ⇒ no run is live. Throws ⇒
|
|
44
|
+
* the lane refused the frame (settled `dropped`, receipt sent). */
|
|
45
|
+
inject?: (entry: PeerHeldEntry) => void;
|
|
46
|
+
/** The receipt to the sender (the binding routes it into the sender's box). */
|
|
47
|
+
receipt: (entry: PeerHeldEntry, state: PeerDeliveryReceiptState) => void;
|
|
48
|
+
/** Announce an entry entering the queue (or its cause changing on re-judgment). */
|
|
49
|
+
onHeld?: (entry: PeerHeldEntry, heldCount: number, cause: CrossSessionHoldCause, reannounce: boolean) => void;
|
|
50
|
+
/** Announce an entry leaving the queue. */
|
|
51
|
+
onSettled?: (entry: PeerHeldEntry, settlement: PeerHeldSettlement, reason: PeerHeldSettleReason, heldCount: number) => void;
|
|
52
|
+
}
|
|
53
|
+
export interface PeerHeldQueueConfig {
|
|
54
|
+
sink: PeerHeldQueueSink;
|
|
55
|
+
/** The resolved `dialogExpiry`: ms before a REVIEW-cause hold expires; `null` = never. */
|
|
56
|
+
dialogExpiryMs: number | null;
|
|
57
|
+
}
|
|
58
|
+
export type PeerHeldSettleOutcome = "gone" | "delivered" | "released_pending" | "dropped" | "dropped_by_policy" | "denied" | "expired";
|
|
59
|
+
export interface PeerHeldQueue {
|
|
60
|
+
/** The registry key (scope + box). */
|
|
61
|
+
readonly key: string;
|
|
62
|
+
/** Bind (or re-bind) the live seats. Idempotent per run; a later bind replaces an earlier one. */
|
|
63
|
+
configure(cfg: PeerHeldQueueConfig): void;
|
|
64
|
+
/** The bound run ended: approvals now park as `released`; deadlines and receipts keep working. */
|
|
65
|
+
detachLeg(): void;
|
|
66
|
+
/** `true` once {@link configure} ran at least once (the queue can route receipts). */
|
|
67
|
+
configured(): boolean;
|
|
68
|
+
/** Park a message (CC `v` hold arm): evict-oldest when full, arm the deadline for a review cause,
|
|
69
|
+
* announce, receipt `held`. */
|
|
70
|
+
hold(entry: Omit<PeerHeldEntry, "heldAt" | "released">): void;
|
|
71
|
+
/** Re-judge every entry against current settings (CC `D`): released ⇒ delivered (or parked
|
|
72
|
+
* `released` with no live run), refused ⇒ refused with a receipt, still held ⇒ re-announced only
|
|
73
|
+
* when the cause moved. */
|
|
74
|
+
rejudge(): {
|
|
75
|
+
released: number;
|
|
76
|
+
refused: number;
|
|
77
|
+
held: number;
|
|
78
|
+
};
|
|
79
|
+
/** The review face (CC `l0e`): approve re-judges then delivers (or parks `released` with no live
|
|
80
|
+
* run), deny/cancel drop with the matching receipt. `"gone"` when nothing is held under that seq. */
|
|
81
|
+
settle(seq: number, decision: "approve" | "deny" | "cancel"): PeerHeldSettleOutcome;
|
|
82
|
+
list(): readonly PeerHeldEntry[];
|
|
83
|
+
size(): number;
|
|
84
|
+
/** CC `HBe` — a graceful shutdown settles everything still held as `expired` with receipts. */
|
|
85
|
+
shutdown(): void;
|
|
86
|
+
}
|
|
87
|
+
export declare function createPeerHeldQueue(key: string, opts?: {
|
|
88
|
+
cap?: number;
|
|
89
|
+
clock?: PeerClock;
|
|
90
|
+
}): PeerHeldQueue;
|
|
91
|
+
export declare function peerHeldQueueKey(scope: string, sessionId: string): string;
|
|
92
|
+
/** The held queue of a session (created on first use). `clock` is honored only at creation. */
|
|
93
|
+
export declare function peerHeldQueueFor(scope: string, sessionId: string, opts?: {
|
|
94
|
+
cap?: number;
|
|
95
|
+
clock?: PeerClock;
|
|
96
|
+
}): PeerHeldQueue;
|
|
97
|
+
/** The held queue of a session if one exists in this process (a host review face reads through this
|
|
98
|
+
* without creating state for sessions that never held anything). */
|
|
99
|
+
export declare function peerHeldQueueIfAny(scope: string, sessionId: string): PeerHeldQueue | undefined;
|
|
100
|
+
/** Test seam: drop every queue (timers cleared, NO receipts — the crash form). Never called by production code. */
|
|
101
|
+
export declare function resetPeerHeldQueuesForTests(): void;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { PEER_HELD_QUEUE_CAP, PEER_HELD_REVIEW_CAUSES } from "./peer-notices.js";
|
|
2
|
+
import { peerSessionBoxHandle } from "./peer-directory.js";
|
|
3
|
+
export const realPeerClock = {
|
|
4
|
+
now: () => Date.now(),
|
|
5
|
+
setTimeout: (fn, ms) => {
|
|
6
|
+
const h = setTimeout(fn, ms);
|
|
7
|
+
h.unref?.();
|
|
8
|
+
return h;
|
|
9
|
+
},
|
|
10
|
+
clearTimeout: (h) => clearTimeout(h),
|
|
11
|
+
};
|
|
12
|
+
const crashHandlers = new WeakMap();
|
|
13
|
+
export function createPeerHeldQueue(key, opts = {}) {
|
|
14
|
+
const cap = opts.cap ?? PEER_HELD_QUEUE_CAP;
|
|
15
|
+
const clock = opts.clock ?? realPeerClock;
|
|
16
|
+
const entries = [];
|
|
17
|
+
const deadlines = new Map();
|
|
18
|
+
let cfg;
|
|
19
|
+
const sinkOrThrow = () => {
|
|
20
|
+
if (cfg === undefined)
|
|
21
|
+
throw new Error("PeerHeldQueue: not configured — bind a run (configure) before holding messages");
|
|
22
|
+
return cfg.sink;
|
|
23
|
+
};
|
|
24
|
+
const safe = (f) => {
|
|
25
|
+
try {
|
|
26
|
+
f?.();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const clearDeadline = (seq) => {
|
|
32
|
+
const h = deadlines.get(seq);
|
|
33
|
+
if (h !== undefined) {
|
|
34
|
+
clock.clearTimeout(h);
|
|
35
|
+
deadlines.delete(seq);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const remove = (entry) => {
|
|
39
|
+
const i = entries.indexOf(entry);
|
|
40
|
+
if (i !== -1)
|
|
41
|
+
entries.splice(i, 1);
|
|
42
|
+
clearDeadline(entry.seq);
|
|
43
|
+
};
|
|
44
|
+
const settleOut = (entry, settlement, reason, receipt) => {
|
|
45
|
+
const sink = sinkOrThrow();
|
|
46
|
+
remove(entry);
|
|
47
|
+
if (receipt !== undefined)
|
|
48
|
+
safe(() => sink.receipt(entry, receipt));
|
|
49
|
+
safe(() => sink.onSettled?.(entry, settlement, reason, entries.length));
|
|
50
|
+
};
|
|
51
|
+
const armDeadline = (entry) => {
|
|
52
|
+
if (cfg === undefined || cfg.dialogExpiryMs === null || deadlines.has(entry.seq))
|
|
53
|
+
return;
|
|
54
|
+
if (entry.released === true)
|
|
55
|
+
return;
|
|
56
|
+
if (!PEER_HELD_REVIEW_CAUSES.includes(entry.cause))
|
|
57
|
+
return;
|
|
58
|
+
const h = clock.setTimeout(() => {
|
|
59
|
+
deadlines.delete(entry.seq);
|
|
60
|
+
if (!entries.includes(entry))
|
|
61
|
+
return;
|
|
62
|
+
rejudge();
|
|
63
|
+
if (!entries.includes(entry))
|
|
64
|
+
return;
|
|
65
|
+
settleOut(entry, "expired", "deadline", "expired");
|
|
66
|
+
}, cfg.dialogExpiryMs);
|
|
67
|
+
deadlines.set(entry.seq, h);
|
|
68
|
+
};
|
|
69
|
+
const tryDeliver = (entry, reason) => {
|
|
70
|
+
const sink = sinkOrThrow();
|
|
71
|
+
if (sink.inject === undefined)
|
|
72
|
+
return "no_leg";
|
|
73
|
+
try {
|
|
74
|
+
sink.inject(entry);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
settleOut(entry, "dropped", "inject_failed", "dropped");
|
|
78
|
+
return "dropped";
|
|
79
|
+
}
|
|
80
|
+
settleOut(entry, "delivered", reason, "delivered");
|
|
81
|
+
return "delivered";
|
|
82
|
+
};
|
|
83
|
+
const rejudge = () => {
|
|
84
|
+
const sink = sinkOrThrow();
|
|
85
|
+
let released = 0;
|
|
86
|
+
let refused = 0;
|
|
87
|
+
const reannounce = [];
|
|
88
|
+
for (const entry of [...entries]) {
|
|
89
|
+
let verdict;
|
|
90
|
+
try {
|
|
91
|
+
verdict = sink.judge(entry);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (verdict.verdict === "refuse") {
|
|
97
|
+
settleOut(entry, "refused", "rejudge", "refused");
|
|
98
|
+
refused += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (verdict.verdict === "deliver" || entry.released === true) {
|
|
102
|
+
const r = tryDeliver(entry, entry.released === true ? "approved" : "rejudge");
|
|
103
|
+
if (r === "no_leg")
|
|
104
|
+
entry.released = true;
|
|
105
|
+
else if (r === "delivered")
|
|
106
|
+
released += 1;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (verdict.cause !== entry.cause) {
|
|
110
|
+
entry.cause = verdict.cause;
|
|
111
|
+
reannounce.push([entry, verdict.cause]);
|
|
112
|
+
clearDeadline(entry.seq);
|
|
113
|
+
armDeadline(entry);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [entry, cause] of reannounce)
|
|
117
|
+
safe(() => sink.onHeld?.(entry, entries.length, cause, true));
|
|
118
|
+
return { released, refused, held: entries.length };
|
|
119
|
+
};
|
|
120
|
+
const queue = {
|
|
121
|
+
key,
|
|
122
|
+
configure(next) {
|
|
123
|
+
cfg = next;
|
|
124
|
+
for (const entry of entries) {
|
|
125
|
+
if (next.dialogExpiryMs === null)
|
|
126
|
+
clearDeadline(entry.seq);
|
|
127
|
+
else
|
|
128
|
+
armDeadline(entry);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
detachLeg() {
|
|
132
|
+
if (cfg === undefined)
|
|
133
|
+
return;
|
|
134
|
+
const { inject: _inject, ...rest } = cfg.sink;
|
|
135
|
+
void _inject;
|
|
136
|
+
cfg = { ...cfg, sink: rest };
|
|
137
|
+
},
|
|
138
|
+
configured: () => cfg !== undefined,
|
|
139
|
+
hold(input) {
|
|
140
|
+
const sink = sinkOrThrow();
|
|
141
|
+
if (entries.some((e) => e.box === input.box && e.seq === input.seq))
|
|
142
|
+
return;
|
|
143
|
+
const entry = { ...input, heldAt: clock.now() };
|
|
144
|
+
if (entries.length >= cap) {
|
|
145
|
+
const oldest = entries[0];
|
|
146
|
+
settleOut(oldest, "expired", "evicted", "expired");
|
|
147
|
+
}
|
|
148
|
+
entries.push(entry);
|
|
149
|
+
armDeadline(entry);
|
|
150
|
+
safe(() => sink.onHeld?.(entry, entries.length, entry.cause, false));
|
|
151
|
+
safe(() => sink.receipt(entry, "held"));
|
|
152
|
+
},
|
|
153
|
+
rejudge,
|
|
154
|
+
settle(seq, decision) {
|
|
155
|
+
const sink = sinkOrThrow();
|
|
156
|
+
const entry = entries.find((e) => e.seq === seq);
|
|
157
|
+
if (entry === undefined)
|
|
158
|
+
return "gone";
|
|
159
|
+
if (decision === "deny") {
|
|
160
|
+
settleOut(entry, "denied", "denied", "denied");
|
|
161
|
+
return "denied";
|
|
162
|
+
}
|
|
163
|
+
if (decision === "cancel") {
|
|
164
|
+
settleOut(entry, "expired", "cancelled", "expired");
|
|
165
|
+
return "expired";
|
|
166
|
+
}
|
|
167
|
+
let verdict;
|
|
168
|
+
try {
|
|
169
|
+
verdict = sink.judge(entry);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
entry.released = true;
|
|
173
|
+
return "released_pending";
|
|
174
|
+
}
|
|
175
|
+
if (verdict.verdict === "refuse") {
|
|
176
|
+
settleOut(entry, "refused", "approved", "refused");
|
|
177
|
+
return "dropped_by_policy";
|
|
178
|
+
}
|
|
179
|
+
const r = tryDeliver(entry, "approved");
|
|
180
|
+
if (r === "no_leg") {
|
|
181
|
+
entry.released = true;
|
|
182
|
+
clearDeadline(entry.seq);
|
|
183
|
+
return "released_pending";
|
|
184
|
+
}
|
|
185
|
+
return r;
|
|
186
|
+
},
|
|
187
|
+
list: () => entries.map((e) => ({ ...e, ...(e.hopChain !== undefined ? { hopChain: [...e.hopChain] } : {}), ...(e.peerMeta !== undefined ? { peerMeta: { ...e.peerMeta } } : {}) })),
|
|
188
|
+
size: () => entries.length,
|
|
189
|
+
shutdown() {
|
|
190
|
+
if (cfg === undefined) {
|
|
191
|
+
entries.length = 0;
|
|
192
|
+
for (const h of deadlines.values())
|
|
193
|
+
clock.clearTimeout(h);
|
|
194
|
+
deadlines.clear();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const entry of [...entries])
|
|
198
|
+
settleOut(entry, "expired", "shutdown", "expired");
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
crashHandlers.set(queue, () => {
|
|
202
|
+
entries.length = 0;
|
|
203
|
+
for (const h of deadlines.values())
|
|
204
|
+
clock.clearTimeout(h);
|
|
205
|
+
deadlines.clear();
|
|
206
|
+
});
|
|
207
|
+
return queue;
|
|
208
|
+
}
|
|
209
|
+
const heldQueues = new Map();
|
|
210
|
+
export function peerHeldQueueKey(scope, sessionId) {
|
|
211
|
+
return JSON.stringify([scope, peerSessionBoxHandle(sessionId)]);
|
|
212
|
+
}
|
|
213
|
+
export function peerHeldQueueFor(scope, sessionId, opts = {}) {
|
|
214
|
+
const key = peerHeldQueueKey(scope, sessionId);
|
|
215
|
+
let q = heldQueues.get(key);
|
|
216
|
+
if (q === undefined) {
|
|
217
|
+
q = createPeerHeldQueue(key, opts);
|
|
218
|
+
heldQueues.set(key, q);
|
|
219
|
+
}
|
|
220
|
+
return q;
|
|
221
|
+
}
|
|
222
|
+
export function peerHeldQueueIfAny(scope, sessionId) {
|
|
223
|
+
return heldQueues.get(peerHeldQueueKey(scope, sessionId));
|
|
224
|
+
}
|
|
225
|
+
export function resetPeerHeldQueuesForTests() {
|
|
226
|
+
for (const q of heldQueues.values())
|
|
227
|
+
crashHandlers.get(q)?.();
|
|
228
|
+
heldQueues.clear();
|
|
229
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { MailboxPeerFromMode } from "../core/mailbox-store.js";
|
|
2
|
+
import type { CrossSessionInboundVerdict } from "./cross-session-judge.js";
|
|
3
|
+
import { type PeerIdleNoticeKind } from "./peer-notices.js";
|
|
4
|
+
import { type PeerClock } from "./peer-held-queue.js";
|
|
5
|
+
export interface PeerIdleSubscriber {
|
|
6
|
+
/** The requester's session id (reply routing). */
|
|
7
|
+
readonly requester: string;
|
|
8
|
+
/** The requester's display name as its record carried it (display only). */
|
|
9
|
+
readonly requesterName?: string;
|
|
10
|
+
/** The requester's self-attested mode class (gates the detail line through the parity judge). */
|
|
11
|
+
readonly fromMode?: MailboxPeerFromMode;
|
|
12
|
+
readonly selfSent: boolean;
|
|
13
|
+
readonly requestedAt: number;
|
|
14
|
+
}
|
|
15
|
+
export interface PeerIdleTargetNotice {
|
|
16
|
+
kind: Exclude<PeerIdleNoticeKind, "expired">;
|
|
17
|
+
finishedAt?: number;
|
|
18
|
+
/** `idle` only, and only when the parity judge DELIVERS for that subscriber (CC gates it on a
|
|
19
|
+
* pid-verified registry match + parity; sema has parity alone, §7). */
|
|
20
|
+
detail?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PeerIdleTargetSink {
|
|
23
|
+
/** Route one notice to one subscriber (the binding appends it to the requester's box). */
|
|
24
|
+
notify: (sub: PeerIdleSubscriber, notice: PeerIdleTargetNotice) => void;
|
|
25
|
+
/** The parity judge over the subscriber's attestation (detail gate, CC `p(d)`). */
|
|
26
|
+
judge: (sub: PeerIdleSubscriber) => CrossSessionInboundVerdict;
|
|
27
|
+
/** The held buffer's size: while > 0 the session is NOT idle to a subscriber (CC `c0e`). */
|
|
28
|
+
heldCount?: () => number;
|
|
29
|
+
/** The last turn's text, for the one-line detail (CC `getLastTurnText`). */
|
|
30
|
+
lastTurnText?: () => string | undefined;
|
|
31
|
+
/** Host announce: a subscription was recorded/refreshed (CC `onSubscribed`). */
|
|
32
|
+
onSubscribed?: (sub: PeerIdleSubscriber, outcome: "recorded" | "refreshed", live: number) => void;
|
|
33
|
+
}
|
|
34
|
+
export type PeerIdleSubscribeOutcome = "recorded" | "refreshed" | "full";
|
|
35
|
+
export interface PeerIdleTarget {
|
|
36
|
+
configure(sink: PeerIdleTargetSink): void;
|
|
37
|
+
configured(): boolean;
|
|
38
|
+
/** CC `tYt` (sans pid classes): same requester ⇒ refresh; table full ⇒ `"full"` (the caller answers
|
|
39
|
+
* `unavailable`). Already idle ⇒ the fire is (re)scheduled so a late subscriber hears it. */
|
|
40
|
+
subscribe(sub: PeerIdleSubscriber): PeerIdleSubscribeOutcome;
|
|
41
|
+
/** CC `A`: drop subscribers older than the TTL, silently. Returns how many. */
|
|
42
|
+
sweep(now?: number): number;
|
|
43
|
+
/** The session started working (a run began): cancel a pending fire. */
|
|
44
|
+
busy(): void;
|
|
45
|
+
/** The session's run ended with nothing queued: schedule the one-shot `idle` fire (debounced; parked
|
|
46
|
+
* while the held buffer is non-empty and re-checked). `finishedAt` = when the turn ended. */
|
|
47
|
+
idle(finishedAt: number): void;
|
|
48
|
+
/** A graceful session end: fire `exited` (or `idle`, when the session was in fact idle — CC `$Rt`). */
|
|
49
|
+
exited(finishedAt?: number): void;
|
|
50
|
+
list(): readonly PeerIdleSubscriber[];
|
|
51
|
+
size(): number;
|
|
52
|
+
}
|
|
53
|
+
export declare function createPeerIdleTarget(opts?: {
|
|
54
|
+
clock?: PeerClock;
|
|
55
|
+
tableCap?: number;
|
|
56
|
+
ttlMs?: number;
|
|
57
|
+
}): PeerIdleTarget;
|
|
58
|
+
export interface PeerIdleOutstanding {
|
|
59
|
+
/** The target's session id. */
|
|
60
|
+
readonly target: string;
|
|
61
|
+
/** The target's label as the requester knew it at request time (bounded by the producer). */
|
|
62
|
+
readonly label: string;
|
|
63
|
+
readonly requestedAt: number;
|
|
64
|
+
}
|
|
65
|
+
export interface PeerIdleRequesterSink {
|
|
66
|
+
/** The 12 h timer fired with no newer ask to the same target: the requester answers itself `expired`. */
|
|
67
|
+
expired: (o: PeerIdleOutstanding) => void;
|
|
68
|
+
}
|
|
69
|
+
export interface PeerIdleRequester {
|
|
70
|
+
configure(sink: PeerIdleRequesterSink): void;
|
|
71
|
+
configured(): boolean;
|
|
72
|
+
/** CC `rYt`: record an outstanding ask (keeps the last 3 per target; the table caps at 32 across
|
|
73
|
+
* targets — `cap` is the degraded send verdict). */
|
|
74
|
+
request(target: string, label: string): {
|
|
75
|
+
ok: true;
|
|
76
|
+
priors: number;
|
|
77
|
+
} | {
|
|
78
|
+
ok: false;
|
|
79
|
+
reason: "cap";
|
|
80
|
+
};
|
|
81
|
+
/** CC `oYt`: a notice arrived for a target — `idle`/`exited`/`expired` clear every ask to it,
|
|
82
|
+
* `unavailable` clears the oldest one. Returns the label the requester knew, for rendering. */
|
|
83
|
+
settle(target: string, kind: PeerIdleNoticeKind): {
|
|
84
|
+
cleared: number;
|
|
85
|
+
label?: string;
|
|
86
|
+
};
|
|
87
|
+
labelFor(target: string): string | undefined;
|
|
88
|
+
list(): readonly PeerIdleOutstanding[];
|
|
89
|
+
size(): number;
|
|
90
|
+
}
|
|
91
|
+
export declare function createPeerIdleRequester(opts?: {
|
|
92
|
+
clock?: PeerClock;
|
|
93
|
+
cap?: number;
|
|
94
|
+
priorsKept?: number;
|
|
95
|
+
ttlMs?: number;
|
|
96
|
+
}): PeerIdleRequester;
|
|
97
|
+
export interface PeerIdleMachine {
|
|
98
|
+
readonly key: string;
|
|
99
|
+
readonly target: PeerIdleTarget;
|
|
100
|
+
readonly requester: PeerIdleRequester;
|
|
101
|
+
}
|
|
102
|
+
export declare function peerIdleMachineKey(scope: string, sessionId: string): string;
|
|
103
|
+
/** The idle machine of a session (created on first use; `clock` honored at creation). */
|
|
104
|
+
export declare function peerIdleMachineFor(scope: string, sessionId: string, opts?: {
|
|
105
|
+
clock?: PeerClock;
|
|
106
|
+
}): PeerIdleMachine;
|
|
107
|
+
export declare function peerIdleMachineIfAny(scope: string, sessionId: string): PeerIdleMachine | undefined;
|
|
108
|
+
/** Test seam: forget every machine (timers are NOT settled — the crash form). Never called by production code. */
|
|
109
|
+
export declare function resetPeerIdleMachinesForTests(): void;
|