@sema-agent/core 5.48.0 → 5.49.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 +63 -0
- package/dist/agents/agent-transcript-tool.d.ts +1 -1
- package/dist/agents/agent-transcript-tool.js +1 -1
- package/dist/agents/send-message-tool.d.ts +2 -2
- package/dist/agents/send-message-tool.js +2 -2
- package/dist/agents/teacher.d.ts +25 -1
- package/dist/agents/teacher.js +85 -12
- package/dist/core/background-agent-store.d.ts +1 -1
- package/dist/core/background-agent-store.js +5 -4
- package/dist/core/memory-engine/delegation-settlement.d.ts +27 -0
- package/dist/core/memory-engine/delegation-settlement.js +31 -4
- package/dist/core/memory-engine/dual-root.js +11 -0
- package/dist/core/memory-engine/engine.d.ts +6 -1
- package/dist/core/memory-engine/engine.js +136 -21
- package/dist/core/memory-engine/memory-backend-contract.js +33 -0
- package/dist/core/memory-engine/origin-clearance.d.ts +19 -0
- package/dist/core/memory-engine/origin-clearance.js +10 -0
- package/dist/core/memory-engine/provenance-wording.d.ts +15 -1
- package/dist/core/memory-engine/provenance-wording.js +1 -0
- package/dist/core/memory-engine/tools.js +6 -4
- package/dist/core/runner/prepare-task.js +7 -7
- package/dist/core/runner/runtask.d.ts +26 -1
- package/dist/core/runner/runtask.js +18 -2
- package/dist/core/strategy-store.d.ts +180 -3
- package/dist/core/strategy-store.js +172 -23
- package/dist/core/types.d.ts +7 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/stores/file/file-snapshot-store.js +7 -1
- package/dist/stores/file/index.d.ts +8 -0
- package/dist/stores/file/index.js +12 -0
- package/dist/stores/file/session-policy-store.d.ts +0 -13
- package/dist/stores/file/session-policy-store.js +7 -1
- package/dist/stores/file/session-store.d.ts +4 -1
- package/dist/stores/file/session-store.js +7 -1
- package/dist/stores/file/strategy-store.d.ts +97 -0
- package/dist/stores/file/strategy-store.js +340 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,68 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.49.0 — 2026-08-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **`Runner.swapModels({models, tiers?})`** — the model-catalog hot-generation seat: switching
|
|
7
|
+
models is now a zero-restart operation. Atomic (the candidate is tier-expanded and validated
|
|
8
|
+
first; a failed swap leaves the current generation byte-identical); in-flight tasks finish on
|
|
9
|
+
their prepare-resolved models (natural snapshot — documented divergence window = exactly the
|
|
10
|
+
in-flight lifetime); `tiers` omitted keeps current bindings re-applied over the new models.
|
|
11
|
+
Announced via `config.models_swapped` (key counts only). Consumers: a deployment that deferred
|
|
12
|
+
model-catalog changes to a process restart can retire that arm.
|
|
13
|
+
- design/337 (escalation/advisor mode) — the strategy repository becomes real cross-session
|
|
14
|
+
infrastructure:
|
|
15
|
+
- **`FileStrategyStore`** (persistence twin; per-strategy files under hashed scope dirs, atomic
|
|
16
|
+
writes, corrupt-entry quarantine, read-side hardening incl. symlink refusal and byte-bounded
|
|
17
|
+
reads) aggregated as `FileStorageBackend.strategyStore`.
|
|
18
|
+
- **CJK retrieval fixed**: term extraction shares the memory tokenizer (alnum runs + CJK
|
|
19
|
+
bigrams/unigrams) — a pure-CJK strategy was stored but never retrievable; old hits preserved
|
|
20
|
+
via a legacy acceptance arm.
|
|
21
|
+
- `TeacherRunResult.strategiesInjected` (id-level injection audit); store faults contained
|
|
22
|
+
(an optimization's failure no longer kills the run; disclosed via
|
|
23
|
+
`TeacherConfig.onStrategyStoreIncident`); injected strategies ride inside the untrusted
|
|
24
|
+
fence; write-side validation doors (`strategy.entry_invalid`); `seedStrategies` two-phase
|
|
25
|
+
batch install. Export surface +7, additive.
|
|
26
|
+
- Conformance clause 31: an AMBIGUOUS origin representation (disagreeing carriers) is refused on
|
|
27
|
+
add and update by every backend; agreeing multi-carrier shapes stay legal (the refusal keys on
|
|
28
|
+
disagreement — serialization closure). The bundled File backend already refused; the clause is
|
|
29
|
+
the machine face proving all backends answer alike, and it caught the reference template
|
|
30
|
+
lacking the refusal the moment it landed (template fixed in the same commit).
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- A 13-finding memory-engine hardening batch (post-release scan of the 5.47 window; all
|
|
34
|
+
externally disclosed):
|
|
35
|
+
- **Dual-root deployments regain the hold-notice family**: `mergeHarvestReports` now merges
|
|
36
|
+
`HarvestReport.containment` (booleans OR, lists concatenated) — under a two-plane scope
|
|
37
|
+
contract the entire `memory.hold_*` family and the index-rollback announce arm were
|
|
38
|
+
structurally unreachable.
|
|
39
|
+
- Hold lifecycle: a refused instruction intake finalizes its row (no resurrection by
|
|
40
|
+
reconciliation); a failed expired-hold release announces once and consumes its verdict
|
|
41
|
+
(generation-keyed — no infinite re-fire); disposal reporting rides the one signal path.
|
|
42
|
+
- Origin clearance: a foreign deletion is no longer read as "our tombstone committed"
|
|
43
|
+
(`tombstonedAt` sidecar; bare absence refuses with custody retained); a concurrent on-disk
|
|
44
|
+
edit is not overwritten by the clearance re-record (divergence guard, byte-equality
|
|
45
|
+
exemption for pristine projections); a challenged entry cannot be washed through the resume
|
|
46
|
+
path.
|
|
47
|
+
- Read side: the recall-taint judge accepts the Read tool's `path` argument spelling; the
|
|
48
|
+
marked-projection index drop judges path tokens per occurrence (an innocent clean entry
|
|
49
|
+
whose name merely contains a marked path no longer over-drops); a read-only plane under
|
|
50
|
+
carry injects the treated index text; index-drop rewrites are announced when they land.
|
|
51
|
+
- The opaque handle/search rows no longer carry a model-authored entry id verbatim (digest
|
|
52
|
+
re-mint on marked commits; the passive-face zero-model-text contract now holds).
|
|
53
|
+
- `memory_search`'s header states band order truthfully on mixed-band pages (single-band and
|
|
54
|
+
off pages byte-identical).
|
|
55
|
+
- The transcript-integrity notice keys its once-latch by (scope, handle) and carries `scope` in
|
|
56
|
+
the detail — multi-tenant attribution without reverse lookup.
|
|
57
|
+
|
|
58
|
+
### Notes
|
|
59
|
+
- Behavior narrowings (all refusals in existing coded families, disclosed): clearEntryOrigin
|
|
60
|
+
gains three refusals (divergence / bare-absence / challenged-resume); marked adds do not adopt
|
|
61
|
+
model-supplied ids; a failed expired-hold release stops re-firing after one loud announcement;
|
|
62
|
+
refused intakes finalize. Two optional sidecar row members (`OriginClearanceRow.tombstonedAt`,
|
|
63
|
+
`HoldRow.resolvedAt`) — strict coercers accept old rows. No BREAKING changes; no export
|
|
64
|
+
removals.
|
|
65
|
+
|
|
3
66
|
## 5.48.0 — 2026-08-20
|
|
4
67
|
|
|
5
68
|
### Added
|
|
@@ -31,7 +31,7 @@ export interface AgentTranscriptToolOptions {
|
|
|
31
31
|
/** Subagent transcript persistence — the declared-durability integrity-gap disclosure sink (see
|
|
32
32
|
* the SendMessage twin): a durable row with a bound sessionId whose session store attests
|
|
33
33
|
* not_found. Announced once per (handle, process); absent ⇒ the honest per-call degrade alone. */
|
|
34
|
-
onTranscriptIntegrityGap?: (handle: string) => void;
|
|
34
|
+
onTranscriptIntegrityGap?: (handle: string, scope: string | undefined) => void;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Residual observability (lane C) — build the MODEL-VISIBLE **AgentTranscript** tool: read the last N tool steps of a
|
|
@@ -64,7 +64,7 @@ export function createAgentTranscriptTool(opts) {
|
|
|
64
64
|
}
|
|
65
65
|
catch (e) {
|
|
66
66
|
if (e?.code === "not_found") {
|
|
67
|
-
announceTranscriptIntegrityGapOnce(id, opts.onTranscriptIntegrityGap);
|
|
67
|
+
announceTranscriptIntegrityGapOnce(id, access.scope, opts.onTranscriptIntegrityGap);
|
|
68
68
|
}
|
|
69
69
|
return undefined;
|
|
70
70
|
}
|
|
@@ -128,12 +128,12 @@ export interface SendMessageToolOptions {
|
|
|
128
128
|
uplinkRecipient?: PeerSelfRef;
|
|
129
129
|
/**
|
|
130
130
|
* Subagent transcript persistence — the "declared durability contradicted by reality" disclosure
|
|
131
|
-
* sink: called (at most once per handle per process, {@link announceTranscriptIntegrityGapOnce})
|
|
131
|
+
* sink: called (at most once per (scope, handle) per process, {@link announceTranscriptIntegrityGapOnce})
|
|
132
132
|
* when a durable row with a bound sessionId meets a session store that attests `not_found`. The
|
|
133
133
|
* first-party mount wires an EngineNotice (`delegation.transcript_integrity`); absent ⇒ the
|
|
134
134
|
* per-call honest refusal is the only loudness (unchanged behavior).
|
|
135
135
|
*/
|
|
136
|
-
onTranscriptIntegrityGap?: (handle: string) => void;
|
|
136
|
+
onTranscriptIntegrityGap?: (handle: string, scope: string | undefined) => void;
|
|
137
137
|
}
|
|
138
138
|
/** RB-382 — max chars of the `summary` arg (the short recap label, distinct from `message`'s much
|
|
139
139
|
* larger {@link UPLINK_RESULT_MAX}) before ITS OWN truncation. Exported for direct unit-testing only
|
|
@@ -292,7 +292,7 @@ export function createSendMessageTool(opts) {
|
|
|
292
292
|
catch (e) {
|
|
293
293
|
await rollbackDrain();
|
|
294
294
|
if (e?.code === "not_found") {
|
|
295
|
-
announceTranscriptIntegrityGapOnce(handle, opts.onTranscriptIntegrityGap);
|
|
295
|
+
announceTranscriptIntegrityGapOnce(handle, scope, opts.onTranscriptIntegrityGap);
|
|
296
296
|
return queueFullReceipt(`No transcript found for agent ${handle}, so a revival cannot drain the backlog.`);
|
|
297
297
|
}
|
|
298
298
|
return queueFullReceipt(`Its transcript store did not answer (${e instanceof Error ? e.message : String(e)}) — this send did not start a backlog drain; a later resend will try again.`);
|
|
@@ -391,7 +391,7 @@ export function createSendMessageTool(opts) {
|
|
|
391
391
|
catch (e) {
|
|
392
392
|
await rollback();
|
|
393
393
|
if (e?.code === "not_found") {
|
|
394
|
-
announceTranscriptIntegrityGapOnce(handle, opts.onTranscriptIntegrityGap);
|
|
394
|
+
announceTranscriptIntegrityGapOnce(handle, scope, opts.onTranscriptIntegrityGap);
|
|
395
395
|
return {
|
|
396
396
|
content: `Message not sent: no transcript found for agent ${handle} — it cannot be revived. Launch a new agent with the needed context instead.`,
|
|
397
397
|
details: { error: "resume.session_not_found", to },
|
package/dist/agents/teacher.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Runner } from "../core/runner/runtask.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type StrategyOrigin, type StrategyStore, type StrategyStoreIncident } from "../core/strategy-store.js";
|
|
3
3
|
import type { ModelRef, TaskResult, TaskSpec } from "../core/types.js";
|
|
4
4
|
/**
|
|
5
5
|
* Teacher mode (escalation cascade): a cheap "student" does the work; a strong "teacher" is consulted
|
|
@@ -85,6 +85,17 @@ export interface TeacherConfig {
|
|
|
85
85
|
minConfidenceToStore?: number;
|
|
86
86
|
/** Inject retrieved strategies into the objective. Default true when a store+scope are set. */
|
|
87
87
|
injectStrategies?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Disclosure sink for a strategy-store failure this loop CONTAINED (store-fault containment): a
|
|
90
|
+
* `find` that failed injects nothing, a `save` that failed stores nothing — neither ever fails the
|
|
91
|
+
* task (the repository is an optimization; an optimization's fault must not kill the work it was
|
|
92
|
+
* optimizing) — but each is disclosed here so a broken store is discoverable. The one exception
|
|
93
|
+
* stays loud: an error carrying a `config.`-prefixed code (an illegal `retrieveK` etc.) is a
|
|
94
|
+
* PROGRAMMER error and rethrows — containing it would silence the validation door entirely.
|
|
95
|
+
* Callback faults are themselves contained (safe-notify posture); absent ⇒ `console.warn`, once
|
|
96
|
+
* per op kind.
|
|
97
|
+
*/
|
|
98
|
+
onStrategyStoreIncident?: (i: StrategyStoreIncident) => void;
|
|
88
99
|
}
|
|
89
100
|
export type EscalationTrigger = "stuck" | "blocked" | "failed" | "verify-fail";
|
|
90
101
|
export interface TeacherAdvice {
|
|
@@ -105,6 +116,19 @@ export interface EscalationRecord {
|
|
|
105
116
|
export interface TeacherRunResult extends TaskResult {
|
|
106
117
|
/** Escalations that occurred during this run (empty if the student succeeded alone). */
|
|
107
118
|
escalations: EscalationRecord[];
|
|
119
|
+
/**
|
|
120
|
+
* Audit trail of the strategies retrieved into the student's objective for THIS run — id-level (the
|
|
121
|
+
* text is not copied; the id is the join key into the store), with each entry's stored timestamp and
|
|
122
|
+
* provenance stamp. Lets a host connect "what was injected" to "how the run ended" offline, which is
|
|
123
|
+
* the only way retrieval-induced degradation is ever measurable. PRESENT iff ≥1 strategy was
|
|
124
|
+
* injected; ABSENT means no store was configured, injection was disabled, retrieval matched nothing,
|
|
125
|
+
* or the find failed (a failure additionally raises `onStrategyStoreIncident`).
|
|
126
|
+
*/
|
|
127
|
+
strategiesInjected?: Array<{
|
|
128
|
+
id: string;
|
|
129
|
+
ts: string;
|
|
130
|
+
origin?: StrategyOrigin;
|
|
131
|
+
}>;
|
|
108
132
|
/** Usage spent on the teacher (advisor), separate from the student's `stats` (which includes the
|
|
109
133
|
* cheap helper monitor/verifier calls). `tasks` = teacher runs (escalation asks + any takeover).
|
|
110
134
|
* `humanReview` = the HITL-gate burden of the teacher's runs (notably a takeover that hit an approval
|
package/dist/agents/teacher.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { MAX_STRATEGY_INJECTION_TOTAL_BYTES, MAX_STRATEGY_PROBLEM_BYTES, MAX_STRATEGY_TEXT_BYTES } from "../core/strategy-store.js";
|
|
3
|
+
import { createSafeNotifier, observeThenableRejection } from "../core/safe-notify.js";
|
|
2
4
|
import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
|
|
3
5
|
import { isDefineToolProduct, stampDefineToolBrand } from "../core/tools.js";
|
|
4
6
|
import { delimitUntrusted, inlineUntrusted } from "../core/untrusted-text.js";
|
|
@@ -46,31 +48,102 @@ export function parseTeacherAdvice(text) {
|
|
|
46
48
|
export async function runWithTeacher(runner, studentSpec, teacher = {}) {
|
|
47
49
|
const store = teacher.strategyStore;
|
|
48
50
|
const scope = teacher.scope;
|
|
51
|
+
const incidentNotifier = createSafeNotifier();
|
|
52
|
+
const warnedIncidentOps = new Set();
|
|
53
|
+
const discloseStoreIncident = (i) => {
|
|
54
|
+
const seat = teacher.onStrategyStoreIncident;
|
|
55
|
+
if (seat) {
|
|
56
|
+
const site = `teacher.onStrategyStoreIncident.${i.op}`;
|
|
57
|
+
incidentNotifier.notify(() => observeThenableRejection(seat(i), incidentNotifier, site), site);
|
|
58
|
+
}
|
|
59
|
+
else if (!warnedIncidentOps.has(i.op)) {
|
|
60
|
+
warnedIncidentOps.add(i.op);
|
|
61
|
+
console.warn(`runWithTeacher: strategy store ${i.op} failed (contained — the run continues): ${i.error}`);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const isConfigCoded = (e) => typeof e?.code === "string" && (e.code).startsWith("config.");
|
|
49
65
|
let spec = studentSpec;
|
|
66
|
+
let injected;
|
|
50
67
|
if (store && scope && teacher.injectStrategies !== false) {
|
|
51
|
-
|
|
68
|
+
let found = [];
|
|
69
|
+
try {
|
|
70
|
+
const raw = await store.find(scope, studentSpec.objective, teacher.retrieveK ?? 3);
|
|
71
|
+
if (!Array.isArray(raw)) {
|
|
72
|
+
discloseStoreIncident({ op: "find", error: "store.find returned a non-array result — injecting nothing" });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
let dropped = 0;
|
|
76
|
+
for (const e of raw) {
|
|
77
|
+
const c = e;
|
|
78
|
+
const id = c?.id;
|
|
79
|
+
const ts = c?.ts;
|
|
80
|
+
const strategy = c?.strategy;
|
|
81
|
+
if (typeof id !== "string" || typeof ts !== "string" || typeof strategy !== "string") {
|
|
82
|
+
dropped++;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const origin = c?.origin;
|
|
86
|
+
found.push({ id, ts, strategy, ...(origin === "earned" || origin === "seeded" ? { origin } : {}) });
|
|
87
|
+
}
|
|
88
|
+
if (dropped > 0) {
|
|
89
|
+
discloseStoreIncident({ op: "find", error: `${dropped} malformed retrieval entries dropped (id/ts/strategy must be strings)` });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (isConfigCoded(e))
|
|
95
|
+
throw e;
|
|
96
|
+
discloseStoreIncident({ op: "find", error: String(e?.message ?? e) });
|
|
97
|
+
found = [];
|
|
98
|
+
}
|
|
99
|
+
let budget = 0;
|
|
100
|
+
for (let i = 0; i < found.length; i++) {
|
|
101
|
+
budget += Buffer.byteLength(found[i].strategy, "utf8");
|
|
102
|
+
if (budget > MAX_STRATEGY_INJECTION_TOTAL_BYTES) {
|
|
103
|
+
discloseStoreIncident({
|
|
104
|
+
op: "find",
|
|
105
|
+
error: `injection truncated to ${i} of ${found.length} retrieved strategies — combined text exceeded ${MAX_STRATEGY_INJECTION_TOTAL_BYTES} bytes`,
|
|
106
|
+
});
|
|
107
|
+
found = found.slice(0, i);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
52
111
|
if (found.length > 0) {
|
|
53
112
|
const block = `[STRATEGIES from PAST, DIFFERENT tasks — they may NOT apply. If unsure, IGNORE them and proceed normally.]\n` +
|
|
54
|
-
found.map((s) => `- ${s.strategy}`).join("\n");
|
|
113
|
+
delimitUntrusted("PAST STRATEGIES (from DIFFERENT tasks; may NOT apply — verify before use)", found.map((s) => `- ${s.strategy}`).join("\n"));
|
|
55
114
|
spec = { ...studentSpec, objective: `${block}\n\n${studentSpec.objective}` };
|
|
115
|
+
injected = found.map((s) => ({ id: s.id, ts: s.ts, ...(s.origin !== undefined ? { origin: s.origin } : {}) }));
|
|
56
116
|
}
|
|
57
117
|
}
|
|
58
118
|
const result = await runTeacherCore(runner, spec, teacher);
|
|
59
119
|
if (store && scope && result.status === "completed" && result.escalations.length > 0) {
|
|
60
120
|
const advice = result.escalations[result.escalations.length - 1].teacher;
|
|
61
121
|
if (advice.strategy && advice.confidence >= (teacher.minConfidenceToStore ?? 2)) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
122
|
+
try {
|
|
123
|
+
const clip = (text, maxBytes) => {
|
|
124
|
+
let out = text;
|
|
125
|
+
while (Buffer.byteLength(out) > maxBytes)
|
|
126
|
+
out = out.slice(0, -1);
|
|
127
|
+
return out;
|
|
128
|
+
};
|
|
129
|
+
await store.save({
|
|
130
|
+
id: randomUUID(),
|
|
131
|
+
problem: clip(studentSpec.objective, MAX_STRATEGY_PROBLEM_BYTES),
|
|
132
|
+
strategy: clip(advice.strategy, MAX_STRATEGY_TEXT_BYTES),
|
|
133
|
+
confidence: Math.min(3, Math.max(0, advice.confidence)),
|
|
134
|
+
scope,
|
|
135
|
+
ts: new Date().toISOString(),
|
|
136
|
+
teacherModel: typeof teacher.model === "string" ? teacher.model : teacher.model?.id,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
if (isConfigCoded(e))
|
|
141
|
+
throw e;
|
|
142
|
+
discloseStoreIncident({ op: "save", error: String(e?.message ?? e) });
|
|
143
|
+
}
|
|
71
144
|
}
|
|
72
145
|
}
|
|
73
|
-
return result;
|
|
146
|
+
return injected !== undefined && injected.length > 0 ? { ...result, strategiesInjected: injected } : result;
|
|
74
147
|
}
|
|
75
148
|
async function runTeacherCore(runner, studentSpec, teacher) {
|
|
76
149
|
const maxEscalations = teacher.maxEscalations ?? 3;
|
|
@@ -170,7 +170,7 @@ export interface BackgroundAgentRecord {
|
|
|
170
170
|
export declare const REVIVED_ROW_CLEARED_FIELDS: readonly ["settledAt", "stoppedBy", "completionId", "finalOutput", "finalOutputFull", "error", "errorCode", "errorRetryable", "errorKind", "errorRetryAfterMs", "resultIsPartial", "summary", "recentSteps", "editedFiles", "usage"];
|
|
171
171
|
/** Erase {@link REVIVED_ROW_CLEARED_FIELDS} from a record a revival is about to write back. */
|
|
172
172
|
export declare function clearRevivedRowTerminalPayload(record: BackgroundAgentRecord): void;
|
|
173
|
-
export declare function announceTranscriptIntegrityGapOnce(handle: string, sink: ((handle: string) => void) | undefined): void;
|
|
173
|
+
export declare function announceTranscriptIntegrityGapOnce(handle: string, scope: string | undefined, sink: ((handle: string, scope: string | undefined) => void) | undefined): void;
|
|
174
174
|
/** Content-free projection for list reads (design/151 HIGH-1: `summary`/`finalOutput`/`recentSteps`
|
|
175
175
|
* and friends NEVER ride a list — content is get-by-handle only, behind the full predicate). */
|
|
176
176
|
export interface BackgroundAgentRowSummary {
|
|
@@ -22,14 +22,15 @@ export function clearRevivedRowTerminalPayload(record) {
|
|
|
22
22
|
delete record[field];
|
|
23
23
|
}
|
|
24
24
|
const transcriptIntegrityAnnounced = new Set();
|
|
25
|
-
export function announceTranscriptIntegrityGapOnce(handle, sink) {
|
|
25
|
+
export function announceTranscriptIntegrityGapOnce(handle, scope, sink) {
|
|
26
26
|
if (sink === undefined)
|
|
27
27
|
return;
|
|
28
|
-
|
|
28
|
+
const key = `${scope ?? ""}\u0000${handle}`;
|
|
29
|
+
if (transcriptIntegrityAnnounced.has(key))
|
|
29
30
|
return;
|
|
30
|
-
transcriptIntegrityAnnounced.add(
|
|
31
|
+
transcriptIntegrityAnnounced.add(key);
|
|
31
32
|
try {
|
|
32
|
-
sink(handle);
|
|
33
|
+
sink(handle, scope);
|
|
33
34
|
}
|
|
34
35
|
catch {
|
|
35
36
|
}
|
|
@@ -221,6 +221,11 @@ export interface HoldRow {
|
|
|
221
221
|
};
|
|
222
222
|
/** Host valve verdict awaiting the next harvest ("release" commits with cause "static"). */
|
|
223
223
|
resolved?: "release" | "discard";
|
|
224
|
+
/** The verdict's GENERATION (strictly increases on every re-resolution): the settle leg's
|
|
225
|
+
* consumed-verdict exit clears only the exact generation it read, so a host re-resolution
|
|
226
|
+
* racing a failing release attempt is never erased by the stale attempt's cleanup. Absent on
|
|
227
|
+
* rows resolved before the member existed (a generation-less clear matches those). */
|
|
228
|
+
resolvedAt?: number;
|
|
224
229
|
}
|
|
225
230
|
export declare function readHolds(controlDir: string): HoldRow[];
|
|
226
231
|
/** ATOMIC no-replace restore of a staged file onto a plane path (adversarial round 2: an
|
|
@@ -308,6 +313,28 @@ export declare function resolveHoldRecord(controlDir: string, input: {
|
|
|
308
313
|
requestId: string;
|
|
309
314
|
now: () => number;
|
|
310
315
|
}): void;
|
|
316
|
+
/**
|
|
317
|
+
* Consume a standing valve verdict WITHOUT any other transition — the settle leg's exit for a
|
|
318
|
+
* valve release that FAILED on an already-disposed row (an expired hold whose release hit a
|
|
319
|
+
* judgment refusal: lost custody, re-scan refusal, CAS conflict). `disposeHold` is a no-op there
|
|
320
|
+
* (the row is already terminal), so leaving `resolved` standing would re-run the identical failing
|
|
321
|
+
* release — and re-announce a disposal that never happened — on every subsequent harvest, forever.
|
|
322
|
+
* Clearing it returns the row to its plain disposed/expired state: the failure is disclosed by the
|
|
323
|
+
* caller, custody/quarantine bytes stay addressable, and `resolveHold(holdId, "release")` remains
|
|
324
|
+
* admissible after the host reconciles. GENERATION-KEYED: only the exact verdict generation the
|
|
325
|
+
* caller read is cleared — a host re-resolution that landed while the failing attempt was in
|
|
326
|
+
* flight is a NEWER generation and stands (the stale attempt's cleanup must not erase a live host
|
|
327
|
+
* action). A generation-less verdict (a row written before the member existed) is consumable
|
|
328
|
+
* against a generation-less read — every CURRENT writer mints a generation, so the only verdict
|
|
329
|
+
* such a clear can erase is one written by a PRE-generation writer racing on the same control
|
|
330
|
+
* plane: the standing mixed-version-writer class the upgrade-order duty prices, kept consumable
|
|
331
|
+
* on purpose (refusing it would revive the forever-re-firing valve loop for every verdict minted
|
|
332
|
+
* before the upgrade). Idempotent; unknown/verdict-less/other-generation rows are a no-op.
|
|
333
|
+
*/
|
|
334
|
+
export declare function clearHoldResolution(controlDir: string, input: {
|
|
335
|
+
holdId: string;
|
|
336
|
+
ifResolvedAt: number | undefined;
|
|
337
|
+
}): void;
|
|
311
338
|
/** Read one hold's custody bytes (release leg + expired-release valve). Returns undefined when
|
|
312
339
|
* the custody file is gone or fails its digest — the caller records capture_lost, never commits
|
|
313
340
|
* unverified bytes. For an expired-released row the bytes may already sit in quarantine
|
|
@@ -346,6 +346,7 @@ function coerceHolds(raw) {
|
|
|
346
346
|
!HOLD_STATUSES.has(row.status) ||
|
|
347
347
|
(row.disposition !== undefined && (typeof row.disposition !== "object" || row.disposition === null || !HOLD_TERMINALS.has(row.disposition.terminal) || (row.disposition.quarantineName !== undefined && !safeBasename(row.disposition.quarantineName)))) ||
|
|
348
348
|
(row.resolved !== undefined && row.resolved !== "release" && row.resolved !== "discard") ||
|
|
349
|
+
(row.resolvedAt !== undefined && (typeof row.resolvedAt !== "number" || !Number.isSafeInteger(row.resolvedAt) || row.resolvedAt < 0 || row.resolvedAt >= 2 ** 50 || row.resolved === undefined)) ||
|
|
349
350
|
(row.entryId !== undefined && typeof row.entryId !== "string") ||
|
|
350
351
|
(row.baseRev !== undefined && typeof row.baseRev !== "string") ||
|
|
351
352
|
(row.op === "update" && (!reqStr(row.entryId) || !reqStr(row.baseRev))) ||
|
|
@@ -401,6 +402,14 @@ export function openInstructionHold(controlDir, input) {
|
|
|
401
402
|
const digest = sha256(buf);
|
|
402
403
|
const captureName = `${holdId}.md`;
|
|
403
404
|
const holdDir = join(controlDir, HOLD_DIR);
|
|
405
|
+
const refuse = (reason, terminal) => {
|
|
406
|
+
try {
|
|
407
|
+
disposeHold(controlDir, { holdId, terminal, now: input.now });
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
}
|
|
411
|
+
return { ok: false, holdId, reason };
|
|
412
|
+
};
|
|
404
413
|
try {
|
|
405
414
|
lockedStrictUpdate(controlDir, HOLDS_FILE, "memory instruction-hold ledger", coerceHolds, (rec) => {
|
|
406
415
|
if (rec.rows.some((r) => r.holdId === holdId))
|
|
@@ -431,10 +440,10 @@ export function openInstructionHold(controlDir, input) {
|
|
|
431
440
|
writeFileSync(join(holdDir, captureName), buf, { flag: "wx" });
|
|
432
441
|
const back = readFileSync(join(holdDir, captureName));
|
|
433
442
|
if (sha256(back) !== digest)
|
|
434
|
-
return
|
|
443
|
+
return refuse("hold capture verification failed (written bytes do not hash to the captured digest)", "capture_lost");
|
|
435
444
|
}
|
|
436
445
|
catch (err) {
|
|
437
|
-
return
|
|
446
|
+
return refuse(`hold capture failed: ${err instanceof Error ? err.message : String(err)}`, "capture_lost");
|
|
438
447
|
}
|
|
439
448
|
const stagingName = uniqueStagingName(holdId, input.now);
|
|
440
449
|
const stagingPath = join(holdDir, stagingName);
|
|
@@ -447,7 +456,7 @@ export function openInstructionHold(controlDir, input) {
|
|
|
447
456
|
if (code === "ENOENT") {
|
|
448
457
|
}
|
|
449
458
|
else {
|
|
450
|
-
return
|
|
459
|
+
return refuse(`hold removal failed (${code ?? "rename error"}): the plane file was not moved`, "discarded");
|
|
451
460
|
}
|
|
452
461
|
}
|
|
453
462
|
if (existsSync(stagingPath)) {
|
|
@@ -475,7 +484,7 @@ export function openInstructionHold(controlDir, input) {
|
|
|
475
484
|
});
|
|
476
485
|
}
|
|
477
486
|
catch (err) {
|
|
478
|
-
return
|
|
487
|
+
return refuse(`hold ledger flip refused: ${err instanceof Error ? err.message : String(err)}`, "discarded");
|
|
479
488
|
}
|
|
480
489
|
return { ok: true, holdId, ...(thirdWriterStranded !== undefined ? { thirdWriterStranded } : {}) };
|
|
481
490
|
}
|
|
@@ -604,6 +613,7 @@ export function markHoldReleased(controlDir, input) {
|
|
|
604
613
|
return { result: undefined };
|
|
605
614
|
r.status = "released";
|
|
606
615
|
delete r.resolved;
|
|
616
|
+
delete r.resolvedAt;
|
|
607
617
|
return { next: rec, result: r.captureName };
|
|
608
618
|
});
|
|
609
619
|
if (captureName === undefined)
|
|
@@ -638,6 +648,23 @@ export function resolveHoldRecord(controlDir, input) {
|
|
|
638
648
|
throw e;
|
|
639
649
|
}
|
|
640
650
|
r.resolved = input.action;
|
|
651
|
+
const nextGeneration = Math.max(input.now(), 0, (r.resolvedAt ?? 0) + 1);
|
|
652
|
+
if (!Number.isSafeInteger(nextGeneration) || nextGeneration < 0 || nextGeneration >= 2 ** 50) {
|
|
653
|
+
const e = new Error(`resolveHold: hold ${JSON.stringify(input.holdId)} cannot mint a fresh verdict generation (${String(nextGeneration)} — an exhausted window, a deranged clock, or a corrupt ledger row) — refused, never written.`);
|
|
654
|
+
e.code = "memory.hold_resolve_invalid_state";
|
|
655
|
+
throw e;
|
|
656
|
+
}
|
|
657
|
+
r.resolvedAt = nextGeneration;
|
|
658
|
+
return { next: rec, result: undefined };
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
export function clearHoldResolution(controlDir, input) {
|
|
662
|
+
lockedStrictUpdate(controlDir, HOLDS_FILE, "memory instruction-hold ledger", coerceHolds, (rec) => {
|
|
663
|
+
const r = rec.rows.find((x) => x.holdId === input.holdId);
|
|
664
|
+
if (r === undefined || r.resolved === undefined || r.resolvedAt !== input.ifResolvedAt)
|
|
665
|
+
return { result: undefined };
|
|
666
|
+
delete r.resolved;
|
|
667
|
+
delete r.resolvedAt;
|
|
641
668
|
return { next: rec, result: undefined };
|
|
642
669
|
});
|
|
643
670
|
}
|
|
@@ -75,5 +75,16 @@ export function mergeHarvestReports(a, b) {
|
|
|
75
75
|
const qf = [...(a.quarantineFailures ?? []), ...(b.quarantineFailures ?? [])];
|
|
76
76
|
if (qf.length > 0)
|
|
77
77
|
merged.quarantineFailures = qf;
|
|
78
|
+
const ac = a.containment;
|
|
79
|
+
const bc = b.containment;
|
|
80
|
+
if (ac !== undefined || bc !== undefined) {
|
|
81
|
+
merged.containment = {
|
|
82
|
+
indexRolledBack: ac?.indexRolledBack === true || bc?.indexRolledBack === true,
|
|
83
|
+
quarantinedInstruction: [...(ac?.quarantinedInstruction ?? []), ...(bc?.quarantinedInstruction ?? [])],
|
|
84
|
+
heldInstruction: [...(ac?.heldInstruction ?? []), ...(bc?.heldInstruction ?? [])],
|
|
85
|
+
releasedHolds: [...(ac?.releasedHolds ?? []), ...(bc?.releasedHolds ?? [])],
|
|
86
|
+
disposedHolds: [...(ac?.disposedHolds ?? []), ...(bc?.disposedHolds ?? [])],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
78
89
|
return merged;
|
|
79
90
|
}
|
|
@@ -803,7 +803,12 @@ export declare class MemoryEngine {
|
|
|
803
803
|
* and a re-call RESUMES it idempotently. Refusals (#123 coded, caller-shape):
|
|
804
804
|
* unattributed/invalid input, unknown/unmarked entry, and a CHALLENGED entry — the clear valve
|
|
805
805
|
* is not a challenge exit (adjudicate the challenge first; clearing a challenged entry would
|
|
806
|
-
* launder the exclusion through the weaker credential)
|
|
806
|
+
* launder the exclusion through the weaker credential); the challenged refusal re-judges on the
|
|
807
|
+
* RESUME path too while the marked entry stands. Two effect-half refusals (terminal, both
|
|
808
|
+
* states untouched): an on-disk projection diverging from the committed state (the clear never
|
|
809
|
+
* overwrites unadopted plane bytes — adopt/reconcile first), and a resumed row whose entry is
|
|
810
|
+
* ABSENT without a recorded committed tombstone (that absence is an independent deletion the
|
|
811
|
+
* clear never resurrects; the custody bytes stay disclosed on the terminal row).
|
|
807
812
|
*/
|
|
808
813
|
clearEntryOrigin(entryId: string, input: {
|
|
809
814
|
requestId: string;
|