@sema-agent/core 5.33.0 → 5.35.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 +98 -0
- package/dist/core/checkpoint-store.d.ts +33 -4
- package/dist/core/hooks.d.ts +98 -3
- package/dist/core/hooks.js +146 -8
- package/dist/core/park-selfcheck.d.ts +156 -0
- package/dist/core/park-selfcheck.js +251 -0
- package/dist/core/push-queue.d.ts +4 -1
- package/dist/core/push-queue.js +2 -1
- package/dist/core/runner/prepare-acquire-reconcile.d.ts +6 -0
- package/dist/core/runner/prepare-acquire-reconcile.js +2 -1
- package/dist/core/runner/prepare-task.d.ts +25 -4
- package/dist/core/runner/prepare-task.js +60 -19
- package/dist/core/runner/runtask.d.ts +6 -1
- package/dist/core/runner/runtask.js +77 -19
- package/dist/core/safe-notify.d.ts +9 -0
- package/dist/core/safe-notify.js +9 -0
- package/dist/core/tool-errors.d.ts +2 -2
- package/dist/core/tool-policy.d.ts +125 -0
- package/dist/core/tool-policy.js +35 -2
- package/dist/core/types.d.ts +71 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/orchestration/workflow.d.ts +1 -1
- package/package.json +2 -1
- package/test/export-surface.snapshot.json +1568 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* design/252 G-6 — the LIVENESS half the wiring manifest deliberately does not have.
|
|
3
|
+
*
|
|
4
|
+
* The manifest's own §0 boundary says it: "this is STATIC ASSEMBLY SELF-DISCLOSURE — seam presence and
|
|
5
|
+
* shape, as facts. It does NOT prove liveness (a callback bound to the wrong run, a dead queue, an
|
|
6
|
+
* adapter's internal failure, an answer routed to the wrong instance). Liveness belongs to round-trip
|
|
7
|
+
* probes, which are a separate obligation." This module is that obligation for the PARK lane, and it
|
|
8
|
+
* quotes the boundary rather than dissolving it: a manifest reporting `parkLane.effective: true` over a
|
|
9
|
+
* store declaring `durability: "durable"` says a store is wired and what it CLAIMS. It cannot say the
|
|
10
|
+
* store accepts a row, returns the same row, and lets the fence win — which is the whole park contract,
|
|
11
|
+
* and the part a deployment gets wrong (a read-only mount, a serializer that drops a nested field, a
|
|
12
|
+
* scope column missing from the WHERE).
|
|
13
|
+
*
|
|
14
|
+
* TWO CHECKS, DIFFERENT KINDS, DELIBERATELY IN ONE MODULE:
|
|
15
|
+
* · {@link probeParkRoundTrip} — the live probe: mint a synthetic parked row, read it back, verify the
|
|
16
|
+
* load-bearing fields survived, fence it, confirm the fence landed.
|
|
17
|
+
* · {@link durableParkGapOf} — the STATIC two-halves reading: a durable park is a durable checkpoint
|
|
18
|
+
* store AND a durable session store, and a deployment with one of them is the configuration whose
|
|
19
|
+
* failure mode is a resume that reports a missing session instead of the seat that is missing. One
|
|
20
|
+
* module because they are one question asked at two costs, and a second home for "what does a whole
|
|
21
|
+
* durable park need" is how the two answers drift.
|
|
22
|
+
*
|
|
23
|
+
* WHAT THE PROBE WRITES (read this before wiring it): it PUTs a real checkpoint row into the real
|
|
24
|
+
* store, under a scope of its own (`{@link PARK_SELFCHECK_SCOPE_PREFIX}<random>`) that no task ever
|
|
25
|
+
* uses, and fences it to `expired` on EVERY exit — including the failing ones, since a pending row is,
|
|
26
|
+
* to an approval inbox, an approval waiting for someone.
|
|
27
|
+
*
|
|
28
|
+
* The expired row then REMAINS, and remains permanently as far as this interface is concerned: there is
|
|
29
|
+
* no delete verb on `CheckpointStore`, and `reap` moves PENDING rows to expired rather than removing
|
|
30
|
+
* expired ones. So each probe run costs one small terminal row under one throwaway scope, and reclaiming
|
|
31
|
+
* them is the backend's own retention concern (a table TTL, a sweep keyed on the scope prefix). An inbox
|
|
32
|
+
* does not see them — `listByScope` enumerates pending rows only — but `listScopes` may still report the
|
|
33
|
+
* scope, and during the probe's own window the row is briefly enumerable and, in principle, resolvable
|
|
34
|
+
* by anything watching the whole store. Both are the price of probing the real lane rather than a
|
|
35
|
+
* pretend one; a deployment that cannot pay it should not run the probe, and there is no read-only
|
|
36
|
+
* variant because a read-only probe would prove exactly what the manifest already discloses.
|
|
37
|
+
*/
|
|
38
|
+
import type { RunnerDeps, TaskSpec } from "./types.js";
|
|
39
|
+
/** The scope prefix every synthetic probe row is filed under — never a task scope, so a probe row can
|
|
40
|
+
* never be mistaken for (or resolved as) a real pending approval. */
|
|
41
|
+
export declare const PARK_SELFCHECK_SCOPE_PREFIX = "sema:park-selfcheck:";
|
|
42
|
+
/**
|
|
43
|
+
* How long any single store call may take before the probe stops waiting and calls it a failure.
|
|
44
|
+
*
|
|
45
|
+
* A bound is MANDATORY rather than optional, for the reason this module exists at all: the probe is a
|
|
46
|
+
* DIAGNOSTIC for storage that may be sick, and the sickness it is most likely to meet — a connection
|
|
47
|
+
* that neither answers nor errors — is exactly the one an unbounded `await` turns into a wedged
|
|
48
|
+
* startup. A probe that hangs instead of reporting is worse than no probe: it fails in the shape it was
|
|
49
|
+
* written to detect, and silently.
|
|
50
|
+
*/
|
|
51
|
+
export declare const PARK_SELFCHECK_STEP_TIMEOUT_MS = 10000;
|
|
52
|
+
/**
|
|
53
|
+
* How far in the FUTURE the synthetic row's abandonment deadline sits.
|
|
54
|
+
*
|
|
55
|
+
* A past deadline would make the row reapable the instant it is filed, and a CORRECT concurrent reaper
|
|
56
|
+
* could then expire it between the probe's own steps — reporting a healthy deployment as a broken one
|
|
57
|
+
* (the read that follows would see an expired row, or the enumeration an empty scope). A short future
|
|
58
|
+
* window keeps the row out of a reaper's reach for the length of the probe while still leaving any
|
|
59
|
+
* residue from a crashed probe reapable shortly after, without an operator doing anything.
|
|
60
|
+
*/
|
|
61
|
+
export declare const PARK_SELFCHECK_ROW_TTL_MS = 60000;
|
|
62
|
+
/**
|
|
63
|
+
* The closed set of things a park round-trip can fail at. A machine keys on these; the accompanying
|
|
64
|
+
* `detail` is prose for a person and is never the discriminator.
|
|
65
|
+
*
|
|
66
|
+
* Ordered as the probe walks the contract, which is also the order an operator debugs in: a store that
|
|
67
|
+
* cannot be written is a different day's work from one that writes and then hands back a row with a
|
|
68
|
+
* dropped field.
|
|
69
|
+
*/
|
|
70
|
+
export type ParkProbeFindingCode =
|
|
71
|
+
/** `put` threw — the store did not accept a checkpoint at all. */
|
|
72
|
+
"put_failed"
|
|
73
|
+
/** `get` threw, or answered `null` for a token the store had just accepted. */
|
|
74
|
+
| "row_not_readable"
|
|
75
|
+
/** The row came back, but a load-bearing field did not survive the round trip (see `detail` for which). */
|
|
76
|
+
| "row_not_faithful"
|
|
77
|
+
/** The row came back with a status other than `pending` — a park nobody can resolve. */
|
|
78
|
+
| "row_not_pending"
|
|
79
|
+
/** `listByScope` is implemented and did NOT list the pending row: the inbox enumeration is blind to it. */
|
|
80
|
+
| "row_not_enumerable"
|
|
81
|
+
/** `expire` threw, or lost a CAS it was the only contender for — the fence the reaper and
|
|
82
|
+
* `TaskStream.destroy` both depend on does not close. */
|
|
83
|
+
| "fence_failed"
|
|
84
|
+
/** The fence reported a win and the row is still `pending` on a re-read: the CAS did not land. */
|
|
85
|
+
| "fence_not_durable";
|
|
86
|
+
/** One thing that went wrong, with the prose a person needs to act on it. */
|
|
87
|
+
export interface ParkProbeFinding {
|
|
88
|
+
readonly code: ParkProbeFindingCode;
|
|
89
|
+
readonly detail: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* What one probe run concluded.
|
|
93
|
+
*
|
|
94
|
+
* `"round_trip_ok"` is the ONLY affirmative word, and it is only ever reported with an empty
|
|
95
|
+
* `findings` list — the invariant is asserted rather than assumed, because a probe whose failure can be
|
|
96
|
+
* read as a pass is worse than no probe (it is the manifest's disclosure wearing a verification's
|
|
97
|
+
* clothes, which is precisely what the §0 boundary forbids).
|
|
98
|
+
*/
|
|
99
|
+
export interface ParkSelfCheckResult {
|
|
100
|
+
readonly verdict: "round_trip_ok" | "failed" | "not_probed";
|
|
101
|
+
/** NON-EMPTY ⟺ `verdict === "failed"`. The two non-failing verdicts carry none: `"round_trip_ok"`
|
|
102
|
+
* because nothing went wrong, `"not_probed"` because nothing was attempted (its reason is in
|
|
103
|
+
* {@link summary} — a finding there would name a defect in a store that was never touched). */
|
|
104
|
+
readonly findings: readonly ParkProbeFinding[];
|
|
105
|
+
/** The scope the synthetic row was filed under, so an operator can find (and reap) the residue.
|
|
106
|
+
* Present from the moment a write was ATTEMPTED — including a `put` that threw, because a backend can
|
|
107
|
+
* commit and then surface a transport error, so a scope on a failed attempt is exactly where an
|
|
108
|
+
* operator has to look. Absent only on `"not_probed"`, where nothing was attempted. */
|
|
109
|
+
readonly scope?: string;
|
|
110
|
+
/** A single sentence naming the outcome — for a startup log line. Never the machine discriminator. */
|
|
111
|
+
readonly summary: string;
|
|
112
|
+
/** The static two-halves reading taken alongside the probe (see {@link durableParkGapOf}); absent when
|
|
113
|
+
* the durable topology is whole or does not apply. A probe can pass every round-trip check on a
|
|
114
|
+
* deployment whose parks still cannot be resumed after a restart, and this is that fact. */
|
|
115
|
+
readonly durableTopologyGap?: string;
|
|
116
|
+
/** Additive, on `"round_trip_ok"` only: the expire CAS reported a LOSS while the row nonetheless
|
|
117
|
+
* closed. From outside this is indistinguishable from the store's own housekeeping getting there
|
|
118
|
+
* first — so the verdict stays green — but a backend that transitions the row and then mis-reports
|
|
119
|
+
* the CAS win would pass on exactly this arm, and `TaskStream.destroy` trusts that boolean. A
|
|
120
|
+
* machine-readable member rather than a summary sentence, so a harness that wants to alert on it
|
|
121
|
+
* can (the summary is never the discriminator). */
|
|
122
|
+
readonly casLossObserved?: true;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* design/252 G-6 (sibling finding, from the G-5 demo) — the DURABLE PARK's two halves, and the sentence
|
|
126
|
+
* to say when only one of them is wired.
|
|
127
|
+
*
|
|
128
|
+
* A park that survives a restart needs BOTH: the checkpoint row (the question) and the session (the
|
|
129
|
+
* conversation the answer resumes into). A deployment that wires a durable checkpoint store over the
|
|
130
|
+
* default in-memory session store parks perfectly and then, after a restart, fails the resume with
|
|
131
|
+
* "session … does not exist" — a SYMPTOM, reported at the seat that is present, naming nothing about
|
|
132
|
+
* the seat that is missing. Whoever reads that message goes looking for a lost session id.
|
|
133
|
+
*
|
|
134
|
+
* Returns the naming sentence, or `undefined` when there is nothing to say: both halves durable (whole),
|
|
135
|
+
* or no checkpoint store at all (the deployment is not doing durable parks, so there is no gap — the
|
|
136
|
+
* manifest's `parkLane.capable: false` is the fact there).
|
|
137
|
+
*/
|
|
138
|
+
export declare function durableParkGapOf(halves: {
|
|
139
|
+
checkpointDurable: boolean;
|
|
140
|
+
checkpointWired: boolean;
|
|
141
|
+
sessionDurable: boolean;
|
|
142
|
+
}): string | undefined;
|
|
143
|
+
/** The two-halves reading taken off a deployment's own seats. Split from {@link durableParkGapOf} so the
|
|
144
|
+
* sentence can be unit-tested without a Runner and the seat reading has one home. */
|
|
145
|
+
export declare function durableParkGapFor(deps: Pick<RunnerDeps, "checkpointStore" | "sessionStore">, spec?: Pick<TaskSpec, "checkpointStore">): string | undefined;
|
|
146
|
+
/**
|
|
147
|
+
* design/252 G-6 — drive one synthetic park through the wired store and report what the assembly can
|
|
148
|
+
* actually DO, as opposed to what it declares.
|
|
149
|
+
*
|
|
150
|
+
* Never throws for a store's failure: every step's exception is captured into a {@link ParkProbeFinding}
|
|
151
|
+
* so a startup self-check can decide for itself whether a dead park lane is fatal. That decision is the
|
|
152
|
+
* deployment's, deliberately — this module reports, loudly and in a closed vocabulary, and does not
|
|
153
|
+
* legislate. What it will NOT do is report a pass it did not observe: `"round_trip_ok"` is returned only
|
|
154
|
+
* with an empty findings list, and the invariant is asserted below rather than left to reading.
|
|
155
|
+
*/
|
|
156
|
+
export declare function probeParkRoundTrip(deps: Pick<RunnerDeps, "checkpointStore" | "sessionStore">, spec?: Pick<TaskSpec, "checkpointStore">): Promise<ParkSelfCheckResult>;
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { boundInputHashOf, canonicalize } from "./canonical-json.js";
|
|
2
|
+
import { MAX_SUPPORTED_CHECKPOINT_VERSION, mintCheckpointToken, resolveCheckpointStore } from "./checkpoint-store.js";
|
|
3
|
+
import { resolveDeclaredDurability } from "./wiring-manifest.js";
|
|
4
|
+
export const PARK_SELFCHECK_SCOPE_PREFIX = "sema:park-selfcheck:";
|
|
5
|
+
export const PARK_SELFCHECK_STEP_TIMEOUT_MS = 10_000;
|
|
6
|
+
export const PARK_SELFCHECK_ROW_TTL_MS = 60_000;
|
|
7
|
+
async function within(op, step) {
|
|
8
|
+
let timer;
|
|
9
|
+
try {
|
|
10
|
+
return await Promise.race([
|
|
11
|
+
Promise.resolve()
|
|
12
|
+
.then(op)
|
|
13
|
+
.then((ok) => ({ ok }))
|
|
14
|
+
.catch((threw) => ({ threw })),
|
|
15
|
+
new Promise((resolve) => {
|
|
16
|
+
timer = setTimeout(() => resolve({ timedOut: `${step} did not answer within ${PARK_SELFCHECK_STEP_TIMEOUT_MS}ms — a store that neither answers nor errors is the failure this probe exists to surface, not one it may wait on` }), PARK_SELFCHECK_STEP_TIMEOUT_MS);
|
|
17
|
+
}),
|
|
18
|
+
]);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
if (timer !== undefined)
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function declaresDurable(store, name) {
|
|
26
|
+
try {
|
|
27
|
+
return resolveDeclaredDurability(store, name) === "durable";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function durableParkGapOf(halves) {
|
|
34
|
+
if (!halves.checkpointWired)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (halves.checkpointDurable && halves.sessionDurable)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (halves.checkpointDurable && !halves.sessionDurable) {
|
|
39
|
+
return ("this deployment wires a checkpoint store that declares durability but a session store that does not " +
|
|
40
|
+
"(RunnerDeps.sessionStore — absent means the built-in in-memory store): a durable park needs BOTH halves, " +
|
|
41
|
+
"so a parked approval survives the restart while the conversation it resumes into does not, and a resume " +
|
|
42
|
+
"arriving afterwards finds the checkpoint and not the session");
|
|
43
|
+
}
|
|
44
|
+
if (!halves.checkpointDurable && halves.sessionDurable) {
|
|
45
|
+
return ("this deployment wires a session store that declares durability but a checkpoint store that does not " +
|
|
46
|
+
"(RunnerDeps.checkpointStore.durability): a durable park needs BOTH halves, so the conversation survives " +
|
|
47
|
+
"the restart while the parked approval that was to resume it does not");
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
export function durableParkGapFor(deps, spec = {}) {
|
|
52
|
+
const checkpointStore = resolveCheckpointStore(spec, deps);
|
|
53
|
+
return durableParkGapOf({
|
|
54
|
+
checkpointWired: checkpointStore !== undefined,
|
|
55
|
+
checkpointDurable: declaresDurable(checkpointStore, "checkpointStore"),
|
|
56
|
+
sessionDurable: declaresDurable(deps.sessionStore, "sessionStore"),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function syntheticCheckpoint(scope) {
|
|
60
|
+
return {
|
|
61
|
+
token: mintCheckpointToken(),
|
|
62
|
+
scope,
|
|
63
|
+
sessionId: `${scope}:session`,
|
|
64
|
+
leafId: `${scope}:leaf`,
|
|
65
|
+
gate: { kind: "human", reason: "park wiring self-check (design/252 G-6) — no person is being asked", toolName: "SelfCheck" },
|
|
66
|
+
pendingAction: {
|
|
67
|
+
kind: "tool_approval",
|
|
68
|
+
toolCallId: `${scope}:call`,
|
|
69
|
+
toolName: "SelfCheck",
|
|
70
|
+
args: { probe: true, nested: { depth: 2, list: [1, 2, 3] } },
|
|
71
|
+
boundInputHash: boundInputHashOf({ probe: true, nested: { depth: 2, list: [1, 2, 3] } }),
|
|
72
|
+
batchToolCallIds: [`${scope}:call`],
|
|
73
|
+
completedCallIds: [],
|
|
74
|
+
},
|
|
75
|
+
state: { activeTools: ["SelfCheck"], nestedStats: { tokens: 0, turns: 0, tasks: 0, costMicroUsd: 0, anyUnpriced: false } },
|
|
76
|
+
status: "pending",
|
|
77
|
+
createdAt: Date.now(),
|
|
78
|
+
version: MAX_SUPPORTED_CHECKPOINT_VERSION,
|
|
79
|
+
suspendCount: 1,
|
|
80
|
+
suspendedAt: Date.now(),
|
|
81
|
+
resourceLedger: { spentMicroUsd: 0, spentTokens: 0, spentTurns: 0, sliceCount: 1 },
|
|
82
|
+
humanReview: { count: 1, totalWaitMs: 0, gates: [{ kind: "human", waitMs: 0, decision: "approve" }] },
|
|
83
|
+
sourceTaskId: `${scope}:task`,
|
|
84
|
+
principal: `${scope}:principal`,
|
|
85
|
+
durableApproval: { scope, ttlMs: PARK_SELFCHECK_ROW_TTL_MS },
|
|
86
|
+
deadline: Date.now() + PARK_SELFCHECK_ROW_TTL_MS,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function faithfulnessDelta(sent, got) {
|
|
90
|
+
const project = (cp) => ({
|
|
91
|
+
token: cp.token,
|
|
92
|
+
scope: cp.scope,
|
|
93
|
+
sessionId: cp.sessionId,
|
|
94
|
+
leafId: cp.leafId,
|
|
95
|
+
gate: cp.gate,
|
|
96
|
+
pendingAction: cp.pendingAction,
|
|
97
|
+
state: cp.state,
|
|
98
|
+
deadline: cp.deadline,
|
|
99
|
+
createdAt: cp.createdAt,
|
|
100
|
+
version: cp.version,
|
|
101
|
+
suspendCount: cp.suspendCount,
|
|
102
|
+
suspendedAt: cp.suspendedAt,
|
|
103
|
+
resourceLedger: cp.resourceLedger,
|
|
104
|
+
humanReview: cp.humanReview,
|
|
105
|
+
sourceTaskId: cp.sourceTaskId,
|
|
106
|
+
principal: cp.principal,
|
|
107
|
+
durableApproval: cp.durableApproval,
|
|
108
|
+
});
|
|
109
|
+
const a = canonicalize(project(sent));
|
|
110
|
+
const b = canonicalize(project(got));
|
|
111
|
+
return a === b ? undefined : `the row came back different from the row that was filed (sent ${a}; read ${b})`;
|
|
112
|
+
}
|
|
113
|
+
export async function probeParkRoundTrip(deps, spec = {}) {
|
|
114
|
+
const gap = durableParkGapFor(deps, spec);
|
|
115
|
+
const gapCell = gap !== undefined ? { durableTopologyGap: gap } : {};
|
|
116
|
+
const store = resolveCheckpointStore(spec, deps);
|
|
117
|
+
if (store === undefined) {
|
|
118
|
+
return {
|
|
119
|
+
verdict: "not_probed",
|
|
120
|
+
findings: [],
|
|
121
|
+
summary: "no checkpoint store is wired on this assembly — there is no park lane to probe (the manifest reports the same fact as parkLane.capable: false)",
|
|
122
|
+
...gapCell,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const scope = `${PARK_SELFCHECK_SCOPE_PREFIX}${mintCheckpointToken()}`;
|
|
126
|
+
const cp = syntheticCheckpoint(scope);
|
|
127
|
+
const said = (err) => {
|
|
128
|
+
try {
|
|
129
|
+
const m = err instanceof Error ? err.message : undefined;
|
|
130
|
+
return typeof m === "string" ? m : `a ${typeof err} the store threw`;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return "a value the store threw that could not be described";
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const shown = (v) => {
|
|
137
|
+
try {
|
|
138
|
+
return typeof v === "string" ? JSON.stringify(v) : `a ${typeof v}`;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return "an undescribable value";
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const fenceQuietly = async () => {
|
|
145
|
+
await within(async () => {
|
|
146
|
+
try {
|
|
147
|
+
await store.expire(cp.token, scope);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
}
|
|
151
|
+
}, "the cleanup fence");
|
|
152
|
+
};
|
|
153
|
+
const failed = async (code, detail) => {
|
|
154
|
+
await fenceQuietly();
|
|
155
|
+
return { verdict: "failed", findings: [{ code, detail }], scope, summary: `the park lane failed its round-trip self-check at "${code}": ${detail}`, ...gapCell };
|
|
156
|
+
};
|
|
157
|
+
let putStarted;
|
|
158
|
+
const put = await within(() => {
|
|
159
|
+
const p = Promise.resolve().then(() => store.put(cp.token, cp));
|
|
160
|
+
putStarted = p;
|
|
161
|
+
return p;
|
|
162
|
+
}, "put");
|
|
163
|
+
if ("timedOut" in put) {
|
|
164
|
+
void putStarted?.catch(() => { }).then(() => store.expire(cp.token, scope)).catch(() => { });
|
|
165
|
+
return await failed("put_failed", put.timedOut);
|
|
166
|
+
}
|
|
167
|
+
if ("threw" in put)
|
|
168
|
+
return await failed("put_failed", `the checkpoint store refused to file a synthetic park: ${said(put.threw)}`);
|
|
169
|
+
const first = await within(() => store.get(cp.token), "get");
|
|
170
|
+
if ("timedOut" in first)
|
|
171
|
+
return await failed("row_not_readable", first.timedOut);
|
|
172
|
+
if ("threw" in first)
|
|
173
|
+
return await failed("row_not_readable", `the checkpoint store threw reading back the row it had just accepted: ${said(first.threw)}`);
|
|
174
|
+
const read = first.ok;
|
|
175
|
+
if (read === null)
|
|
176
|
+
return await failed("row_not_readable", "the checkpoint store accepted the row and then answered null for its own token — a park filed here would be unresumable");
|
|
177
|
+
if (typeof read !== "object")
|
|
178
|
+
return await failed("row_not_readable", `the checkpoint store answered ${read === undefined ? "undefined" : typeof read} for its own token — neither a checkpoint nor the absence of one`);
|
|
179
|
+
let delta;
|
|
180
|
+
let status;
|
|
181
|
+
try {
|
|
182
|
+
delta = faithfulnessDelta(cp, read);
|
|
183
|
+
status = read.status;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
return await failed("row_not_faithful", `the row the store returned could not be read for comparison: ${said(err)}`);
|
|
187
|
+
}
|
|
188
|
+
if (delta !== undefined)
|
|
189
|
+
return await failed("row_not_faithful", delta);
|
|
190
|
+
if (status !== "pending")
|
|
191
|
+
return await failed("row_not_pending", `the filed row came back with status ${shown(status)} — a park in any other status is one nobody can resolve`);
|
|
192
|
+
const enumerable = store.listByScope !== undefined;
|
|
193
|
+
if (store.listByScope !== undefined) {
|
|
194
|
+
const listByScope = store.listByScope.bind(store);
|
|
195
|
+
const listed = await within(() => listByScope(scope), "listByScope");
|
|
196
|
+
if ("timedOut" in listed)
|
|
197
|
+
return await failed("row_not_enumerable", listed.timedOut);
|
|
198
|
+
if ("threw" in listed)
|
|
199
|
+
return await failed("row_not_enumerable", `listByScope threw for the probe's own scope: ${said(listed.threw)}`);
|
|
200
|
+
let present;
|
|
201
|
+
try {
|
|
202
|
+
present = Array.isArray(listed.ok) && listed.ok.some((row) => row?.token === cp.token);
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
return await failed("row_not_enumerable", `the listByScope result could not be read: ${said(err)}`);
|
|
206
|
+
}
|
|
207
|
+
if (!present)
|
|
208
|
+
return await failed("row_not_enumerable", `listByScope("${scope}") did not include the pending row it holds — an inbox enumerating this scope would show no approvals`);
|
|
209
|
+
}
|
|
210
|
+
const fence = await within(() => store.expire(cp.token, scope), "expire");
|
|
211
|
+
if ("timedOut" in fence)
|
|
212
|
+
return await failed("fence_failed", fence.timedOut);
|
|
213
|
+
if ("threw" in fence)
|
|
214
|
+
return await failed("fence_failed", `expire threw on the probe's own pending row: ${said(fence.threw)}`);
|
|
215
|
+
const fenced = fence.ok === true;
|
|
216
|
+
const second = await within(() => store.get(cp.token), "get (after the fence)");
|
|
217
|
+
if ("timedOut" in second)
|
|
218
|
+
return await failed("fence_not_durable", second.timedOut);
|
|
219
|
+
if ("threw" in second)
|
|
220
|
+
return await failed("fence_not_durable", `the store threw re-reading the fenced row: ${said(second.threw)}`);
|
|
221
|
+
const after = second.ok;
|
|
222
|
+
let afterStatus;
|
|
223
|
+
if (after !== null) {
|
|
224
|
+
if (typeof after !== "object")
|
|
225
|
+
return await failed("fence_not_durable", `the store answered ${after === undefined ? "undefined" : typeof after} re-reading the fenced row — neither a checkpoint nor the absence of one`);
|
|
226
|
+
try {
|
|
227
|
+
afterStatus = after.status;
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
return await failed("fence_not_durable", `the fenced row's status could not be read: ${said(err)}`);
|
|
231
|
+
}
|
|
232
|
+
if (afterStatus !== "expired") {
|
|
233
|
+
return await failed(fenced ? "fence_not_durable" : "fence_failed", fenced
|
|
234
|
+
? `expire reported a win and the row reads ${shown(afterStatus)} — the contract's transition is pending → expired, so the CAS did not land`
|
|
235
|
+
: `expire lost an UNCONTENDED CAS and the row reads ${shown(afterStatus)} — the fence that keeps a checkpoint from being both reaped and resumed does not close here`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const steps = `filed, read back${enumerable ? ", enumerated" : ""} and fenced`;
|
|
239
|
+
const notExercised = enumerable ? "" : " (this store implements no listByScope, so the inbox enumeration was NOT exercised)";
|
|
240
|
+
const casNote = fenced ? "" : " (the expire CAS reported a loss while the row closed — consistent with store housekeeping, but a backend that mis-reports CAS wins is not distinguished here)";
|
|
241
|
+
return {
|
|
242
|
+
verdict: "round_trip_ok",
|
|
243
|
+
findings: [],
|
|
244
|
+
...(fenced ? {} : { casLossObserved: true }),
|
|
245
|
+
scope,
|
|
246
|
+
summary: after === null
|
|
247
|
+
? `the park lane ${steps} a synthetic checkpoint${notExercised} — the row is no longer present (a deleting backend or its own housekeeping removed it), so nothing resumable remains${casNote}`
|
|
248
|
+
: `the park lane ${steps} a synthetic checkpoint${notExercised} — residual expired row in scope "${scope}"${casNote}`,
|
|
249
|
+
...gapCell,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -3,7 +3,10 @@ export declare class PushQueue<T> implements AsyncIterable<T> {
|
|
|
3
3
|
private buffer;
|
|
4
4
|
private waiters;
|
|
5
5
|
private closed;
|
|
6
|
-
|
|
6
|
+
/** Returns whether the value was ACCEPTED (`false` after close — the push is a silent no-op then).
|
|
7
|
+
* Callers that mirror a pushed value to a second reader gate on this so both sides stay in parity
|
|
8
|
+
* (#253: a status frame must not reach a side sink the stream itself already refused). */
|
|
9
|
+
push(value: T): boolean;
|
|
7
10
|
close(): void;
|
|
8
11
|
[Symbol.asyncIterator](): AsyncIterator<T>;
|
|
9
12
|
}
|
package/dist/core/push-queue.js
CHANGED
|
@@ -4,7 +4,7 @@ export class PushQueue {
|
|
|
4
4
|
closed = false;
|
|
5
5
|
push(value) {
|
|
6
6
|
if (this.closed) {
|
|
7
|
-
return;
|
|
7
|
+
return false;
|
|
8
8
|
}
|
|
9
9
|
const waiter = this.waiters.shift();
|
|
10
10
|
if (waiter) {
|
|
@@ -13,6 +13,7 @@ export class PushQueue {
|
|
|
13
13
|
else {
|
|
14
14
|
this.buffer.push(value);
|
|
15
15
|
}
|
|
16
|
+
return true;
|
|
16
17
|
}
|
|
17
18
|
close() {
|
|
18
19
|
this.closed = true;
|
|
@@ -40,6 +40,12 @@ export interface PrepareAcquireReconcileInput {
|
|
|
40
40
|
* {@link import("./prepare-safety-scan.js").PrepareSafetyScanResult}); reconcile classifies an
|
|
41
41
|
* orphaned call's retry-safety by it. */
|
|
42
42
|
toolEffects: Map<string, ToolEffect>;
|
|
43
|
+
/** borrowed-readonly — design/252 G-6 sibling: the durable-park TOPOLOGY gap sentence for this
|
|
44
|
+
* deployment (`durableParkGapFor`), or `undefined` when the topology is whole / does not apply.
|
|
45
|
+
* Read ONLY on the fail-loud missing-session path below, where it turns a symptom into a named
|
|
46
|
+
* absent seat; nothing in the phase's control flow depends on it. Computed by the driver (which
|
|
47
|
+
* holds `deps`) rather than here, keeping this slice's input the narrow Pick its contract says. */
|
|
48
|
+
durableParkGap?: string;
|
|
43
49
|
}
|
|
44
50
|
/** The phase's outputs (design/238 相 API 规则件 four-class form). All five are fresh bindings —
|
|
45
51
|
* the driver destructures them into consts, so a consumer moved ahead of this call is a lexical
|
|
@@ -39,7 +39,8 @@ export async function prepareAcquireReconcile(input) {
|
|
|
39
39
|
}
|
|
40
40
|
catch (err) {
|
|
41
41
|
if (spec.requireExistingSession && err?.code === "not_found") {
|
|
42
|
-
const e = new Error(`requireExistingSession: session "${spec.sessionId}" does not exist — refusing a silent fresh run (design/114 Phase3)`
|
|
42
|
+
const e = new Error(`requireExistingSession: session "${spec.sessionId}" does not exist — refusing a silent fresh run (design/114 Phase3)` +
|
|
43
|
+
(input.durableParkGap !== undefined ? `. Note the assembly: ${input.durableParkGap}` : ""));
|
|
43
44
|
e.code = "resume.session_not_found";
|
|
44
45
|
throw e;
|
|
45
46
|
}
|
|
@@ -169,9 +169,14 @@ export interface Prepared {
|
|
|
169
169
|
* emits; the tool_result-side delete in prepare-task never fires for immediate results). */
|
|
170
170
|
blockedToolCalls: Set<string>;
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
173
|
-
* tool gate, at the one exit where an ask resolved,
|
|
174
|
-
* is minted (the reader deletes on read; a call the
|
|
172
|
+
* WHAT ended the approval a gated call was waiting on and — design/252 G-7 — WHOSE settlement it
|
|
173
|
+
* was, keyed by tool-call id: written ONLY by the tool gate, at the one exit where an ask resolved,
|
|
174
|
+
* and read once when that call's `tool_end` frame is minted (the reader deletes on read; a call the
|
|
175
|
+
* gate never settled has no entry, and an entry never names neither fact).
|
|
176
|
+
*
|
|
177
|
+
* ONE record rather than two parallel maps because they are one observation: an attribution without
|
|
178
|
+
* the settlement kind beside it is unreadable ("alice" — approved? her window elapsed?), and two maps
|
|
179
|
+
* keyed alike are two chances to drain one and leak the other.
|
|
175
180
|
*
|
|
176
181
|
* It is a sideband and not a field on the tool RESULT because a result is not a trustworthy carrier
|
|
177
182
|
* for this: `details` is arbitrary tool-authored data that post-tool hooks may also replace, so a
|
|
@@ -180,7 +185,10 @@ export interface Prepared {
|
|
|
180
185
|
* adjudicating layer can write. Same reason the entries are keyed by CALL id: the gate adjudicated
|
|
181
186
|
* that exact call, and the frame that reads it is that call's own.
|
|
182
187
|
*/
|
|
183
|
-
|
|
188
|
+
approvalSettlement: Map<string, {
|
|
189
|
+
settledBy?: import("../tool-policy.js").ApprovalSettledBy;
|
|
190
|
+
approver?: string;
|
|
191
|
+
}>;
|
|
184
192
|
/** Summed usage of nested sub-runs (sub-agents) spawned by this task's tools. */
|
|
185
193
|
nestedStats: NestedUsageAccum;
|
|
186
194
|
/** RB-430-a: prepare-time rewind disclosures (conversation-only branch / no snapshot backend / no file
|
|
@@ -1285,6 +1293,19 @@ export interface RunInternals {
|
|
|
1285
1293
|
* MODEL context (this is purely a render channel). Absent unless the deployment opted in.
|
|
1286
1294
|
*/
|
|
1287
1295
|
onForwardEvent?: (event: TaskEvent) => void;
|
|
1296
|
+
/**
|
|
1297
|
+
* #253 — the run's OWN top-level `status` TaskEvent stream (brain liveness: rate-limit/retry/
|
|
1298
|
+
* reconnect/circuit-open), offered to the internals holder beside the queue. The queue alone was
|
|
1299
|
+
* enough for a direct `runTask` caller (the TaskStream carries these frames), but a COMPOSITION
|
|
1300
|
+
* entry (verify/cascade) drains its inner legs' queues itself — without this seat, an inner leg's
|
|
1301
|
+
* retry disclosure died inside the gate and the wire showed a silent stall. Fed the SAME frame
|
|
1302
|
+
* object the queue receives, at the same moment; contained by the run's safe notifier (#248 form:
|
|
1303
|
+
* a throwing sink is swallowed, first failure per site disclosed, never faults the leg). Subagent
|
|
1304
|
+
* frames still ride {@link onForwardEvent} — this seat is ONLY the run's own status type.
|
|
1305
|
+
*/
|
|
1306
|
+
onStatusEvent?: (event: Extract<TaskEvent, {
|
|
1307
|
+
type: "status";
|
|
1308
|
+
}>) => void;
|
|
1288
1309
|
/**
|
|
1289
1310
|
* design/115 P2 core slice — trusted run-local system-injection sink. `Runner.runLocked` wires this to the
|
|
1290
1311
|
* live TaskStream queue plus the current harness follow-up lane; it is not a public TaskSpec field.
|