@argszero/dsh-cancel-cause-guard 0.1.0 → 0.2.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/README.md CHANGED
@@ -8,8 +8,9 @@ The harness ships an `Agent.cancel(cause)` whose argument is a **closed** union
8
8
  `{ kind: 'disposed' }` — and that union is enforced by TypeScript alone. Nothing on
9
9
  the write path refuses a value outside it, and one such value does not merely log
10
10
  something odd: it ends the session's usefulness. This plugin normalizes the
11
- out-of-union value at the last moment the harness still lets a plugin see it, so the
12
- turn ends with a readable reason instead of `null`.
11
+ out-of-union value at the last moment the harness still lets a plugin see it, and
12
+ backstops the session's own `append` so that even a cancellation it never gets to
13
+ see cannot commit an unreadable row.
13
14
 
14
15
  ## The defect
15
16
 
@@ -31,10 +32,10 @@ throws before the catch body assigns `turnEnds`, which is still at its `null`
31
32
  initializer, and the `finally` appends unconditionally:
32
33
 
33
34
  ```ts
34
- let turnEnds: TurnEndReason | null = null // agent.ts:310
35
+ let turnEnds: TurnEndReason | null = null // agent.ts:312
35
36
  ...
36
37
  } finally {
37
- this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:385
38
+ this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:387
38
39
  }
39
40
  ```
40
41
 
@@ -46,7 +47,7 @@ dereferences it then throws — the chat client does `location.turn.end?.data.re
46
47
  while rendering — and one such row blanks the entire conversation area, for every
47
48
  session, including newly created ones.
48
49
 
49
- Reported three times: discussions **#8832**, **#9156** and **#9646**.
50
+ Reported four times: discussions **#8832**, **#9156**, **#9646** and **#9699**.
50
51
 
51
52
  ### The version boundary
52
53
 
@@ -56,8 +57,25 @@ malformed-but-readable row rather than a `null` one. From `0.1.7-rc.2` on, the s
56
57
  abuse is fatal — which is why this is a **guard** and not a report: the shape of the
57
58
  failure changed under callers that had not changed at all.
58
59
 
60
+ ### The two callers an entry guard cannot reach
61
+
62
+ `ReactLoopAgent.cancel` is the only aborter of the signals the loop owns — the
63
+ function's own comment says so — so shadowing that method covers every caller that
64
+ goes through the agent **instance**. Two do not:
65
+
66
+ - a caller that reaches the **class** rather than the instance
67
+ (`ReactLoopAgent.prototype.cancel.call(agent, cause)`, or a second copy of the
68
+ loop in the same process), which no observable hook hands over; and
69
+ - an instance that **refuses** the own `cancel` property, where the entry guard
70
+ reports that it is not in force and nothing protects the session at all.
71
+
72
+ Both end with the same unreadable row. That is what the second half of this plugin
73
+ is for.
74
+
59
75
  ## What it does
60
76
 
77
+ ### 1. Normalize the cause at the entry
78
+
61
79
  It listens to `agent/created` — the serial, awaited lifecycle edge that hands over the
62
80
  **live agent instance** (`dsh-agent/src/index.ts:550`) — and shadows that instance's
63
81
  `cancel` with a wrapper that normalizes anything outside the union into
@@ -83,8 +101,35 @@ It listens to `agent/created` — the serial, awaited lifecycle edge that hands
83
101
  detached `const cancel = agent.cancel` still reaches the method with the receiver
84
102
  its body requires.
85
103
 
86
- Every rewrite is logged with the offending value rendered as JSON, so the producer can
87
- be found and fixed rather than tolerated.
104
+ ### 2. Backstop the row at the write
105
+
106
+ The same lifecycle edge hands over the **live session**, and `Session.append` is a
107
+ prototype method of it — so one own-property write shadows the append path for every
108
+ writer, the loop's own rows included. Only one event type is inspected.
109
+
110
+ - **The row it writes is the row the loop meant to write.** When a `turn/end` arrives
111
+ whose reason no reader can dereference, the backstop commits
112
+ `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }` instead: the turn
113
+ *was* interrupted by a cancellation, `aborted` is a member every reader knows, and
114
+ `hook` is the member that says the cancellation came from outside the union.
115
+ - **It does not invent a variant.** `TurnEndReasonMap` is merge-extensible, so a new
116
+ `kind` would be a value the readers have never seen — trading a loud failure for a
117
+ silent one.
118
+ - **The test is dereferenceability, not membership.** A reason is left alone when it
119
+ is a non-null object carrying a non-empty string `kind`, because a plugin may
120
+ legitimately extend the map with a variant this plugin knows nothing about. What is
121
+ refused is the shape no reader can read at all: `null`, absent, or not an object.
122
+ - **It never edits the caller's payload.** The replacement is a new object; the one
123
+ the loop handed over is left as it was.
124
+ - **Every other event is forwarded untouched**, whatever it carries, and a
125
+ `turn/end` whose payload is not an object is reported rather than rebuilt — the
126
+ event map promises `{ turn, reason }`, and writing the missing half would be
127
+ inventing a durable row.
128
+ - **It is switchable off** with `backstop: false`, for an operator who wants the
129
+ append path to be exactly the harness's.
130
+
131
+ Every cause rewrite and every repaired row is logged, with the offending value
132
+ rendered as JSON, so the producer can be found and fixed rather than tolerated.
88
133
 
89
134
  ## Install
90
135
 
@@ -114,14 +159,16 @@ Or, if you compose `cordis.yml` by hand:
114
159
 
115
160
  | Key | Default | Meaning |
116
161
  |---|---|---|
117
- | `mode` | `'normalize'` | `normalize` rewrites an out-of-union cause to `{ kind: 'hook', reason }`; `observe` passes the caller's value through untouched and only records the producer. |
118
- | `reason` | absent | Reason text to record in place of the value's own name. Absent names the cause after the value that produced it — the only handle on whoever produced it. |
162
+ | `mode` | `'normalize'` | `normalize` rewrites an out-of-union cause to `{ kind: 'hook', reason }` and repairs an unreadable `turn/end` reason; `observe` passes both through untouched and only records what arrived. |
163
+ | `reason` | absent | Reason text to record in place of the value's own name. Absent names the cause after the value that produced it — the only handle on whoever produced it — and names a repaired row after what it is: a cancellation the entry guard never saw. |
119
164
  | `maxReasonChars` | `200` | Bound on the minted reason, in characters. |
165
+ | `backstop` | `true` | Whether the durable backstop is installed as well. `false` leaves the session's own `append` untouched. |
120
166
 
121
167
  A configuration the plugin cannot honour is refused **at mount** rather than quietly
122
168
  replaced: an unknown key, a mode this plugin does not have, a `reason` that would name
123
- nothing, or a bound that is not a positive integer. A misspelled `mode` that mounts and
124
- guards nothing is how an operator believes a guard is in force when it is not.
169
+ nothing, a bound that is not a positive integer, or a `backstop` that is not a boolean.
170
+ A misspelled `mode` that mounts and guards nothing is how an operator believes a guard
171
+ is in force when it is not.
125
172
 
126
173
  ## Diagnostics
127
174
 
@@ -134,46 +181,68 @@ cause, which is not a member of AgentCancelCause; the turn was recorded as
134
181
  Which plugin passes it is the value itself: the reason carries its own name.
135
182
  ```
136
183
 
137
- `observe` mode says the value was passed through instead, so the operator sees the
138
- real failure and the log does not claim a repair.
184
+ One warning per repaired `turn/end` row, in a deliberately different sentence — a row
185
+ this arm repaired is a cancellation the **entry guard never saw**, and an operator has
186
+ to be able to tell the two apart:
187
+
188
+ ```
189
+ cancel-cause-guard: session <id>: a turn/end row for turn 4 reached the log with a
190
+ reason no reader can dereference (null); it was recorded as { "kind": "aborted",
191
+ "reason": { "kind": "hook", "reason": "a cancellation cause the entry guard never
192
+ saw" } }. The cancellation never passed the entry guard, so its cause is not in this
193
+ record: a caller reached the cancel method through the class rather than through the
194
+ instance, a second copy of the loop aborted a signal of its own, or the instance
195
+ refused the guard.
196
+ ```
197
+
198
+ `observe` mode says the value (or the row) was passed through instead, so the operator
199
+ sees the real failure and the log does not claim a repair.
139
200
 
140
- Two refusals are warned about at the moment they matter, because a guard that is
201
+ Four refusals are warned about at the moment they matter, because a guard that is
141
202
  silently absent is the thing an operator cannot see:
142
203
 
143
204
  - an agent that carries no `cancel` method at all;
144
205
  - an agent that refuses an own `cancel` property (a sealed or non-writable instance),
145
206
  where the install is confirmed by **reading the property back**, not by the absence
146
- of a throw.
207
+ of a throw — the durable backstop covers this agent's session all the same;
208
+ - a session that carries no `append` method;
209
+ - a session that refuses an own `append` property.
147
210
 
148
211
  ## What it is not
149
212
 
150
213
  - **It cannot repair a session that already carries a `null` row.** Those rows are on
151
214
  disk and this plugin reads no history. Mounting it bounds the defect from that point
152
215
  on; the sessions that already have one stay unreadable until the row is removed.
153
- - **It does not cover a caller that reaches the class rather than the instance** —
154
- `ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop.
155
- Nothing observable hands that path over, which is why `mode: 'observe'` exists: an
156
- operator who wants to see the failure happen may mount this plugin as a reporter and
157
- still get the producer named in the log.
158
- - **It does not patch the union away.** Values that are already members are neither
159
- copied, rewritten, nor reported.
216
+ - **It does not patch the unions away.** Causes that are already members are neither
217
+ copied, rewritten, nor reported, and a `turn/end` reason a reader can dereference is
218
+ forwarded as it is, whatever variant it names.
160
219
  - **It calls no model and needs no service.** `inject` is empty: the whole seam is the
161
- agent instance the lifecycle edge delivers.
220
+ agent instance and the session the lifecycle edge delivers.
221
+ - **It writes no history.** Both arms only affect rows that are about to be committed;
222
+ a row already in the log is never read, rewritten or deleted.
162
223
 
163
224
  ## Evidence
164
225
 
165
226
  - `test/lifecycle.spec.mjs` — the plugin mounted through a real Cordis context, against
166
- the real `AgentRegistry` and the real `agent/created` dispatch: the guard is installed
167
- by the time the announce dispatch settles, it does not touch an agent published before
168
- the mount, unmounting restores the instance (both when the fiber disposes and when the
169
- agent does), and `world({ mounted: false })` is the control arm for each.
170
- - `test/guard.spec.mjs` — the mechanism: an own property over a prototype method, the
171
- options handed through untouched, the original bound to its agent, the mark a second
172
- mount recognises, the undo that reveals the prototype method again rather than copying
173
- it in, and every refusal.
227
+ the real `AgentRegistry` and the real `agent/created` dispatch, on a real
228
+ `@deepseek-ai/dsh-session` `Session`: the guard and the backstop are installed by the
229
+ time the announce dispatch settles, neither touches an agent published before the
230
+ mount, unmounting restores both properties (when the fiber disposes and when the
231
+ agent does), a sealed instance is still backstopped at the write, and
232
+ `world({ mounted: false })` is the control arm for each — including the arm that
233
+ writes the `{"turn":1,"reason":null}` row **unmounted** and reads it back out of the
234
+ log, which is the defect reproduced against the shipped class rather than described.
235
+ - `test/backstop.spec.mjs` — the second mechanism: an own property over a prototype
236
+ method, the row it writes for each unreadable shape, what it leaves alone (a readable
237
+ reason by reference, a plugin's own variant, every other event type), the payload it
238
+ does not mutate, and every refusal.
239
+ - `test/guard.spec.mjs` — the first mechanism: an own property over a prototype method,
240
+ the options handed through untouched, the original bound to its agent, the mark a
241
+ second mount recognises, the undo that reveals the prototype method again rather than
242
+ copying it in, and every refusal.
174
243
  - `test/cause.spec.mjs` — the pure half: what each shape becomes, what it is called, the
175
244
  bound, and the values that must never throw inside `cancel`.
176
- - `test/config.spec.mjs` — the defaults, detaching, and the five refusals.
245
+ - `test/config.spec.mjs` — the defaults, detaching, and the refusals.
177
246
  - `scripts/witness-cancel-cause.mjs` — the consequence, proved against the **published**
178
247
  `@deepseek-ai/dsh-agent-loop` sources rather than a stand-in: it extracts
179
248
  `abortedCancelCause` and `assertNever` from the shipped tarball, shows a bare-string
@@ -186,7 +255,9 @@ silently absent is the thing an operator cannot see:
186
255
  the test that was supposed to hold the line, and is reported as `SILENT`.
187
256
  - `npm run probe:lines` — runs the suite once per released line, each with every harness
188
257
  package pinned to that line. That measurement is where the supported-lines list below
189
- comes from.
258
+ comes from, and it covers the backstop's seam too: the suite's own session is the
259
+ line's published `Session`, and the unmounted control arm asserts that this line's
260
+ write path accepts `reason: null`.
190
261
 
191
262
  ## Supported lines
192
263
 
package/cordis.patch.yml CHANGED
@@ -15,24 +15,33 @@
15
15
  # surface message slots, `request/header` and `tool/result`, and `turn/end.reason` is
16
16
  # a TypeScript-only invariant. Every reader that dereferences it then throws, and the
17
17
  # chat client does exactly that while rendering, so one such row blanks the
18
- # conversation area. Reported three times: discussions #8832, #9156 and #9646.
18
+ # conversation area. Reported four times: discussions #8832, #9156, #9646 and #9699.
19
19
  #
20
20
  # A harness older than `0.1.7` had no such function and recorded the same abuse as a
21
21
  # malformed-but-readable row, which is why this is a guard: the failure changed shape
22
22
  # under callers that had not changed at all.
23
23
  #
24
- # This plugin closes the seam that is left. It listens to `agent/created` — the
25
- # serial, awaited lifecycle edge that hands over the **live agent instance**
26
- # (`dsh-agent/src/index.ts:550`) — and shadows that instance's `cancel` with a wrapper
27
- # that carries anything outside the union as `{ kind: 'hook', reason: <the value's own
28
- # name> }` before the loop ever sees it. `hook` is the member the union reserves for
29
- # plugins, so the rewrite is not a lie: a plugin did cancel the turn, and the reason
30
- # names which one. One own-property write covers every instance-level caller, because
31
- # `ReactLoopAgent.cancel` is a prototype method.
24
+ # This plugin closes the defect at two depths. **At the entry**, it listens to
25
+ # `agent/created` — the serial, awaited lifecycle edge that hands over the **live agent
26
+ # instance** (`dsh-agent/src/index.ts:550`) — and shadows that instance's `cancel` with
27
+ # a wrapper that carries anything outside the union as `{ kind: 'hook', reason: <the
28
+ # value's own name> }` before the loop ever sees it. `hook` is the member the union
29
+ # reserves for plugins, so the rewrite is not a lie: a plugin did cancel the turn, and
30
+ # the reason names which one. One own-property write covers every instance-level caller,
31
+ # because `ReactLoopAgent.cancel` is a prototype method.
32
32
  #
33
- # It never refuses a cancellation, forwards an already-valid cause by reference, and
34
- # cannot repair rows already on disk (it reads no history). Mounting it bounds the
35
- # defect from that point on.
33
+ # **At the write**, the same edge hands over the **live session**, and the plugin
34
+ # shadows that session's `append` too — so a `turn/end` row whose reason no reader can
35
+ # dereference is committed as `{ kind: 'aborted', reason: { kind: 'hook', reason: … } }`
36
+ # instead. That arm covers the callers the entry guard cannot reach: one that goes
37
+ # through the class rather than the instance
38
+ # (`ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop in the
39
+ # process), and an instance that refuses the own `cancel` property.
40
+ #
41
+ # It never refuses a cancellation, forwards an already-valid cause by reference, leaves
42
+ # any reason a reader can dereference untouched (a plugin's own `TurnEndReasonMap`
43
+ # variant included), and cannot repair rows already on disk (it reads no history).
44
+ # Mounting it bounds the defect from that point on.
36
45
  #
37
46
  # Optional config:
38
47
  #
@@ -50,6 +59,10 @@
50
59
  # # reason: plugin-bug
51
60
  # # Bound on the minted reason, in characters. Defaults to 200.
52
61
  # maxReasonChars: 200
62
+ # # Whether the durable backstop is installed as well (default true).
63
+ # # `false` leaves the session's own `append` untouched, so only the entry
64
+ # # guard is in force — set it if another plugin already owns that method.
65
+ # backstop: true
53
66
  #
54
67
  # An unknown key is refused rather than ignored: a misspelled `mode` that mounts and
55
68
  # guards nothing is how an operator believes a guard is in force when it is not.
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The durable backstop: the `turn/end` row is never committed with a reason the
3
+ * conversation view cannot read.
4
+ *
5
+ * ## Why the cause guard alone is not enough
6
+ *
7
+ * The entry guard (`./guard.ts`) normalizes a cause before the loop ever sees it,
8
+ * and it covers every caller that goes through the **instance** method — which is
9
+ * every caller in the shipped tree. Two callers it cannot cover remain, and both
10
+ * end with the same unreadable row:
11
+ *
12
+ * - a caller that reaches the class rather than the instance
13
+ * (`ReactLoopAgent.prototype.cancel.call(agent, cause)`, or a second copy of the
14
+ * loop in the same process), which no observable hook hands over; and
15
+ * - an agent instance that refuses the own `cancel` property, where the entry
16
+ * guard reports `not-writable` and is simply absent.
17
+ *
18
+ * In both cases the out-of-union cause reaches `abortedCancelCause`'s
19
+ * `assertNever` **inside the turn's `catch`**, `turnEnds` stays at its `null`
20
+ * initializer, and the unconditional `finally` commits
21
+ * `{"type":"turn/end","data":{"turn":N,"reason":null}}`. The write path accepts it
22
+ * — `validateSessionEventData` inspects surface message slots, `request/header`
23
+ * and `tool/result`, and `turn/end.reason` is a TypeScript-only invariant — so
24
+ * the row is durable before any reader can object. The chat client then
25
+ * dereferences `reason.kind` while rendering and throws, which blanks the whole
26
+ * conversation area, for every session, across restarts.
27
+ *
28
+ * ## The seam
29
+ *
30
+ * `Session.append` is a **prototype** method of the session the agent holds, so an
31
+ * own-property write on `agent.session` shadows it for every writer — the loop's
32
+ * own `turn/start` / `step/start` / `turn/end` and any plugin's, alike. Only one
33
+ * event type is inspected; everything else is forwarded untouched.
34
+ *
35
+ * ## What it writes
36
+ *
37
+ * It does not invent a variant. A new `kind` would be a value the readers switch
38
+ * over have never seen, which trades a loud failure for a silent one — and
39
+ * `TurnEndReasonMap` is merge-extensible, so this plugin cannot even enumerate
40
+ * the members. It writes instead **the row the loop meant to write**: the turn was
41
+ * interrupted by a cancellation, and the cause came from outside the closed union,
42
+ * so it is `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }` —
43
+ * `aborted` is a member every reader knows, and `hook` is the member
44
+ * `AgentCancelCause` reserves for whoever outside the union cancelled the turn.
45
+ *
46
+ * The test is deliberately **dereferenceability, not membership**: a reason is
47
+ * left alone when it is a non-null object carrying a non-empty string `kind`,
48
+ * because a plugin may legitimately extend `TurnEndReasonMap` with a variant this
49
+ * plugin knows nothing about. What is refused is the shape no reader can read at
50
+ * all — `null`, absent, or not an object.
51
+ *
52
+ * @module @argszero/dsh-cancel-cause-guard/backstop
53
+ */
54
+ import { truncate } from "./cause.js";
55
+ /**
56
+ * The brand an installed append wrapper carries.
57
+ *
58
+ * `Symbol.for` for the reason {@link GUARD_MARK} uses it: two copies of this
59
+ * plugin in one process must recognise each other's work rather than stack.
60
+ */
61
+ export const APPEND_GUARD_MARK = Symbol.for('argszero.dsh-cancel-cause-guard/append-guarded');
62
+ /** The event type this backstop inspects, and the only one it rewrites. */
63
+ const TURN_END = 'turn/end';
64
+ /**
65
+ * The reason text for a row whose reason carried nothing at all.
66
+ *
67
+ * Named for what the operator has to know: the cancellation did not pass the
68
+ * entry guard, so its cause was never seen and cannot be named.
69
+ */
70
+ export const UNSEEN_CAUSE_REASON = 'a cancellation cause the entry guard never saw';
71
+ /**
72
+ * Whether a value is a reason a reader can dereference.
73
+ *
74
+ * The whole consequence of the defect is one expression — `reason.kind` — so the
75
+ * property this asks about is exactly that one: an object, not null, whose `kind`
76
+ * is non-empty text. Membership in `TurnEndReasonMap` is deliberately **not**
77
+ * asked, because the map is merge-extensible and a plugin's own variant would
78
+ * then be rewritten by a plugin that had never heard of it.
79
+ * @param reason - the value the loop is about to commit as `turn/end.reason`.
80
+ * @returns whether a reader may dereference `reason.kind`.
81
+ */
82
+ export function isReadableTurnEndReason(reason) {
83
+ if (typeof reason !== 'object' || reason === null)
84
+ return false;
85
+ const kind = readKind(reason);
86
+ return typeof kind === 'string' && kind.trim().length > 0;
87
+ }
88
+ /**
89
+ * The reason to commit for a row whose own reason is unreadable.
90
+ *
91
+ * The configured reason wins when the operator set one; otherwise the text says
92
+ * the one thing that is certainly true — no entry guard saw this cancellation.
93
+ * @param options - the configured reason and its bound.
94
+ * @returns the reason text for the replacement row.
95
+ */
96
+ export function unseenCauseReason(options) {
97
+ const text = options.reason ?? UNSEEN_CAUSE_REASON;
98
+ return truncate(text, options.maxReasonChars);
99
+ }
100
+ /**
101
+ * Shadow one session's `append` so an unreadable `turn/end` reason never lands.
102
+ *
103
+ * Every refusal is reported as an outcome rather than thrown: a session this
104
+ * plugin could not guard is a session that keeps the defect, not a reason to fail
105
+ * the lifecycle edge that delivered it.
106
+ * @param session - the live session the agent appends to.
107
+ * @param options - the mode, the reason override, the bound, and the report callback.
108
+ * @returns the outcome and an undo.
109
+ */
110
+ export function guardSessionAppend(session, options) {
111
+ if (session === undefined || session === null)
112
+ return { outcome: 'no-append', restore: () => { } };
113
+ const current = session.append;
114
+ if (typeof current !== 'function')
115
+ return { outcome: 'no-append', restore: () => { } };
116
+ if (current[APPEND_GUARD_MARK] === true) {
117
+ return { outcome: 'already-guarded', restore: () => { } };
118
+ }
119
+ const original = current;
120
+ // Whether the property was the instance's own before the guard: a prototype
121
+ // method must be revealed again by deleting the shadow, an own property by
122
+ // writing the old value back.
123
+ const hadOwn = Object.prototype.hasOwnProperty.call(session, 'append');
124
+ const wrapper = function guardedAppend(...args) {
125
+ const decided = decide(args, options);
126
+ if (decided === undefined)
127
+ return Reflect.apply(original, session, args);
128
+ options.onRepaired(decided.report);
129
+ // In `observe` mode the report is the whole contribution: the unreadable row
130
+ // is committed exactly as it would have been, so the operator sees the real
131
+ // failure, and the log names it rather than claiming a repair.
132
+ if (options.mode === 'observe')
133
+ return Reflect.apply(original, session, args);
134
+ const forwarded = args.slice();
135
+ forwarded[1] = { ...args[1], reason: decided.reason };
136
+ return Reflect.apply(original, session, forwarded);
137
+ };
138
+ wrapper[APPEND_GUARD_MARK] = true;
139
+ try {
140
+ ;
141
+ session.append = wrapper;
142
+ }
143
+ catch {
144
+ // A sealed session, or an `append` defined without a setter.
145
+ return { outcome: 'not-writable', restore: () => { } };
146
+ }
147
+ // A refusal to write is not always a throw — a frozen object in sloppy mode
148
+ // would simply ignore it — so the install is confirmed by reading it back.
149
+ if (session.append !== wrapper) {
150
+ return { outcome: 'not-writable', restore: () => { } };
151
+ }
152
+ let installed = true;
153
+ return {
154
+ outcome: 'patched',
155
+ restore: () => {
156
+ if (!installed)
157
+ return;
158
+ installed = false;
159
+ // Only unwind a shadow that is still ours: a later guard, or the session
160
+ // itself, may have replaced it, and undoing that would be a third party's
161
+ // state being written by this one.
162
+ if (session.append !== wrapper)
163
+ return;
164
+ if (hadOwn)
165
+ session.append = original;
166
+ else
167
+ delete session.append;
168
+ },
169
+ };
170
+ }
171
+ /**
172
+ * What one `append` call should become, if anything.
173
+ *
174
+ * Only `turn/end` with an object payload is eligible. A payload that is not an
175
+ * object is left alone rather than rebuilt: the event map promises `{ turn, reason }`,
176
+ * and a setter that invented the missing half would be writing a row of its own
177
+ * making into a durable log. It is reported instead.
178
+ * @param args - the arguments the caller passed to `append`.
179
+ * @param options - the configured reason and its bound.
180
+ * @returns the reason to commit and the report, or undefined when nothing changes.
181
+ */
182
+ function decide(args, options) {
183
+ if (args[0] !== TURN_END)
184
+ return undefined;
185
+ const data = args[1];
186
+ if (typeof data !== 'object' || data === null || Array.isArray(data))
187
+ return undefined;
188
+ const reason = data.reason;
189
+ if (isReadableTurnEndReason(reason))
190
+ return undefined;
191
+ return {
192
+ reason: { kind: 'aborted', reason: { kind: 'hook', reason: unseenCauseReason(options) } },
193
+ report: {
194
+ turn: data.turn,
195
+ original: reason,
196
+ normalized: { kind: 'hook', reason: unseenCauseReason(options) },
197
+ described: describeReason(reason),
198
+ },
199
+ };
200
+ }
201
+ /** A rendering of the unreadable reason, for the log line. */
202
+ function describeReason(reason) {
203
+ if (reason === undefined)
204
+ return 'absent';
205
+ if (reason === null)
206
+ return 'null';
207
+ const kind = typeof reason === 'object' ? readKind(reason) : undefined;
208
+ if (typeof kind === 'string' && kind.trim().length > 0)
209
+ return `no kind string (${String(kind)})`;
210
+ return Object.prototype.toString.call(reason);
211
+ }
212
+ /** The `kind` a value carries, read without letting a hostile accessor escape. */
213
+ function readKind(value) {
214
+ try {
215
+ return value.kind;
216
+ }
217
+ catch {
218
+ // It runs inside `append`: a guard that threw while inspecting a payload
219
+ // would abort the append in the guard rather than in its caller.
220
+ return undefined;
221
+ }
222
+ }
package/lib/cause.js CHANGED
@@ -193,7 +193,13 @@ function plainText(value) {
193
193
  return UNSPECIFIED_REASON;
194
194
  }
195
195
  }
196
- /** Bound one reason so a hostile value cannot write a wall of text into the log. */
197
- function truncate(text, maxChars) {
196
+ /**
197
+ * Bound one reason so a hostile value cannot write a wall of text into the log.
198
+ * Shared with the durable backstop, which mints a reason of its own.
199
+ * @param text - the reason text.
200
+ * @param maxChars - the bound, in characters.
201
+ * @returns the text, cut to the bound with a trailing ellipsis when it had to be.
202
+ */
203
+ export function truncate(text, maxChars) {
198
204
  return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
199
205
  }
package/lib/config.js CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Configuration resolution for the cancellation-cause guard.
3
3
  *
4
- * The surface is deliberately three fields. A guard that rewrites a value on a
5
- * path as hot as cancellation has to be readable in one sitting, and every knob
6
- * it does not have is a way for an operator to be surprised by what it did.
4
+ * The surface is deliberately four fields. A guard that rewrites a value on
5
+ * paths as hot as cancellation and the session append has to be readable in one
6
+ * sitting, and every knob it does not have is a way for an operator to be
7
+ * surprised by what it did.
7
8
  *
8
9
  * An unknown key is refused rather than ignored: a misspelled `mode` that mounts
9
10
  * and guards nothing is how an operator believes a guard is in force when it is
@@ -17,8 +18,9 @@ export const DEFAULTS = Object.freeze({
17
18
  mode: 'normalize',
18
19
  reason: undefined,
19
20
  maxReasonChars: DEFAULT_MAX_REASON_CHARS,
21
+ backstop: true,
20
22
  });
21
- const CONFIG_KEYS = new Set(['mode', 'reason', 'maxReasonChars']);
23
+ const CONFIG_KEYS = new Set(['mode', 'reason', 'maxReasonChars', 'backstop']);
22
24
  const MODES = new Set(['normalize', 'observe']);
23
25
  /**
24
26
  * Resolve, validate and detach the guard configuration.
@@ -34,7 +36,7 @@ export function resolveConfig(config = {}) {
34
36
  for (const key of Object.keys(config)) {
35
37
  if (!CONFIG_KEYS.has(key)) {
36
38
  throw new Error(`cancel-cause-guard config: unknown key "${key}" `
37
- + '(allowed: mode, reason, maxReasonChars)');
39
+ + '(allowed: mode, reason, maxReasonChars, backstop)');
38
40
  }
39
41
  }
40
42
  const mode = config.mode ?? DEFAULTS.mode;
@@ -55,5 +57,9 @@ export function resolveConfig(config = {}) {
55
57
  + `after the value that produced it (the default resolves to "${UNSPECIFIED_REASON}" only `
56
58
  + 'for a cause that carried no name)');
57
59
  }
58
- return Object.freeze({ mode, reason, maxReasonChars });
60
+ const backstop = config.backstop ?? DEFAULTS.backstop;
61
+ if (typeof backstop !== 'boolean') {
62
+ throw new Error('cancel-cause-guard config: backstop must be true or false');
63
+ }
64
+ return Object.freeze({ mode, reason, maxReasonChars, backstop });
59
65
  }
package/lib/index.js CHANGED
@@ -33,9 +33,12 @@
33
33
  *
34
34
  * ## What this plugin does
35
35
  *
36
- * It listens to `agent/created` — the serial, awaited lifecycle edge that hands
37
- * over the **live agent instance** — and shadows that instance's `cancel` with a
38
- * wrapper that normalizes anything outside the union into
36
+ * It closes the defect at two depths, and the second one exists for the callers
37
+ * the first cannot see.
38
+ *
39
+ * **At the entry.** It listens to `agent/created` — the serial, awaited lifecycle
40
+ * edge that hands over the **live agent instance** — and shadows that instance's
41
+ * `cancel` with a wrapper that normalizes anything outside the union into
39
42
  * `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
40
43
  * it. `hook` is the union member the harness reserves for plugins, so the
41
44
  * rewrite is not a lie: a plugin did cancel the turn, and the reason names which
@@ -44,27 +47,39 @@
44
47
  * third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
45
48
  * the shadow is an own property.
46
49
  *
47
- * Every rewrite is logged, with the offending value rendered as JSON, so the
48
- * producer can be found and fixed rather than merely tolerated.
50
+ * **At the write.** The same edge hands over the **live session**, and the
51
+ * plugin shadows that session's own `append` too, so a `turn/end` row whose
52
+ * reason no reader can dereference is committed instead as the row the loop meant
53
+ * to write: `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }`. That
54
+ * arm is what covers the callers the entry guard cannot reach — one that goes
55
+ * through the class rather than the instance
56
+ * (`ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop
57
+ * in the process), and an instance that refuses the own `cancel` property, where
58
+ * the entry guard simply reports that it is not in force. Set `backstop: false`
59
+ * to leave the append path exactly as the harness's.
60
+ *
61
+ * Every rewrite and every repaired row is logged, with the offending value
62
+ * rendered as JSON, so the producer can be found and fixed rather than merely
63
+ * tolerated.
49
64
  *
50
65
  * ## What it does not do
51
66
  *
52
- * It does not refuse a cancellation, does not touch the options, does not
53
- * rewrite a cause that is already a member (that one is forwarded by reference),
54
- * and cannot repair a session that already carries a `null` row — those rows are
55
- * on disk, and this plugin reads no history. Mounting it bounds the defect from
56
- * that point on.
67
+ * It does not refuse a cancellation, does not touch the options, does not rewrite
68
+ * a cause that is already a member (that one is forwarded by reference), and
69
+ * cannot repair a session that already carries a `null` row — those rows are on
70
+ * disk, and this plugin reads no history. Mounting it bounds the defect from that
71
+ * point on.
57
72
  *
58
- * It also cannot cover the one caller that reaches the class rather than the
59
- * instance, which is why `mode: 'observe'` exists: an operator who wants to see
60
- * the failure happen may mount this plugin as a reporter and still get the
61
- * producer named in the log.
73
+ * `mode: 'observe'` exists for the operator who wants to see the failure happen:
74
+ * both arms report and neither rewrites, and the log names what arrived.
62
75
  *
63
76
  * @module @argszero/dsh-cancel-cause-guard
64
77
  */
78
+ import { guardSessionAppend } from "./backstop.js";
65
79
  import { resolveConfig } from "./config.js";
66
80
  import { guardCancel } from "./guard.js";
67
81
  export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from "./cause.js";
82
+ export { APPEND_GUARD_MARK, UNSEEN_CAUSE_REASON, guardSessionAppend, isReadableTurnEndReason, unseenCauseReason } from "./backstop.js";
68
83
  export { DEFAULTS, resolveConfig } from "./config.js";
69
84
  export { GUARD_MARK, guardCancel } from "./guard.js";
70
85
  /** Cordis plugin name, used by loader diagnostics. */
@@ -93,51 +108,126 @@ const LOG_PREFIX = 'cancel-cause-guard:';
93
108
  */
94
109
  export function apply(ctx, config = {}) {
95
110
  const settings = resolveConfig(config);
96
- /** The guards this plugin installed and has not yet unwound, by agent instance. */
111
+ /** The shadows this plugin installed and has not yet unwound, by agent instance. */
97
112
  const guards = new Map();
98
113
  // Registered before the listeners so that it is unwound *after* them: the
99
114
  // fiber's effects are disposed in reverse, and unwinding a shadow while a
100
115
  // listener could still install another would leave the last one behind.
101
116
  ctx.effect(() => () => {
102
- for (const handle of guards.values())
103
- handle.restore();
117
+ for (const shadows of guards.values())
118
+ restoreShadows(shadows);
104
119
  guards.clear();
105
120
  }, `${LOG_PREFIX}restore()`);
106
121
  ctx.on('agent/created', ({ agent }) => {
122
+ const shadows = {};
123
+ const sessionId = agent.session.id;
107
124
  const handle = guardCancel(agent, {
108
125
  mode: settings.mode,
109
126
  reason: settings.reason,
110
127
  maxReasonChars: settings.maxReasonChars,
111
- onNormalized: report => { reportNormalized(ctx, agent.session.id, report, settings.mode); },
128
+ onNormalized: report => { reportNormalized(ctx, sessionId, report, settings.mode); },
112
129
  });
113
130
  switch (handle.outcome) {
114
131
  case 'patched':
115
- guards.set(agent, handle);
116
- ctx.logger.debug(`${LOG_PREFIX} agent ${agent.session.id}: cancellation causes outside the union will be `
132
+ shadows.cancel = handle;
133
+ ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: cancellation causes outside the union will be `
117
134
  + 'recorded as { kind: "hook", reason } instead of ending the turn with reason null');
118
- return;
135
+ break;
119
136
  case 'already-guarded':
120
- ctx.logger.debug(`${LOG_PREFIX} agent ${agent.session.id}: already guarded by this plugin, leaving the `
137
+ ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: already guarded by this plugin, leaving the `
121
138
  + 'existing wrapper in place');
122
- return;
139
+ break;
123
140
  case 'no-cancel':
124
- ctx.logger.warn(`${LOG_PREFIX} agent ${agent.session.id} carries no cancel method, so an out-of-union `
141
+ ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId} carries no cancel method, so an out-of-union `
125
142
  + 'cause from any plugin it hands itself to will still end its turn with reason null');
126
- return;
143
+ break;
127
144
  /* v8 ignore next 4 -- the read-back check makes both write failures one outcome. */
128
145
  case 'not-writable':
129
- ctx.logger.warn(`${LOG_PREFIX} agent ${agent.session.id} refuses an own "cancel" property, so this guard `
146
+ ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId} refuses an own "cancel" property, so this guard `
130
147
  + 'is NOT in force for it: an out-of-union cause will end its turn with reason null');
131
148
  }
149
+ // The durable half, installed after the entry guard so that the ordering is
150
+ // legible: what the entry guard rewrites never reaches this one, and what
151
+ // reaches this one is exactly the cancellation the entry guard could not see.
152
+ if (settings.backstop) {
153
+ const backstop = guardSessionAppend(agent.session, backstopOptions(ctx, sessionId, settings));
154
+ switch (backstop.outcome) {
155
+ case 'patched':
156
+ shadows.append = backstop;
157
+ ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: a turn/end row that reached the log with no `
158
+ + 'readable reason will be recorded as { kind: "aborted", reason: { kind: "hook", reason } }');
159
+ break;
160
+ case 'already-guarded':
161
+ ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: the session is already backstopped by this plugin, `
162
+ + 'leaving the existing wrapper in place');
163
+ break;
164
+ case 'no-append':
165
+ ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId}: its session carries no append method, so the `
166
+ + 'durable backstop is NOT in force for it');
167
+ break;
168
+ /* v8 ignore next 4 -- the read-back check makes both write failures one outcome. */
169
+ case 'not-writable':
170
+ ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId}: its session refuses an own "append" property, so `
171
+ + 'the durable backstop is NOT in force for it');
172
+ }
173
+ }
174
+ if (shadows.cancel !== undefined || shadows.append !== undefined)
175
+ guards.set(agent, shadows);
132
176
  });
133
177
  ctx.on('agent/disposed', ({ agent }) => {
134
- const handle = guards.get(agent);
135
- if (handle === undefined)
178
+ const shadows = guards.get(agent);
179
+ if (shadows === undefined)
136
180
  return;
137
- handle.restore();
181
+ restoreShadows(shadows);
138
182
  guards.delete(agent);
139
183
  });
140
184
  }
185
+ /**
186
+ * Unwind every shadow of one agent.
187
+ * @param shadows - the handles this plugin installed for that agent.
188
+ */
189
+ function restoreShadows(shadows) {
190
+ shadows.cancel?.restore();
191
+ shadows.append?.restore();
192
+ }
193
+ /**
194
+ * The backstop options for one agent.
195
+ * @param ctx - the plugin context, for logging.
196
+ * @param sessionId - the session the backstop reports against.
197
+ * @param settings - the resolved configuration.
198
+ * @returns the options, with the report callback bound.
199
+ */
200
+ function backstopOptions(ctx, sessionId, settings) {
201
+ return {
202
+ mode: settings.mode,
203
+ reason: settings.reason,
204
+ maxReasonChars: settings.maxReasonChars,
205
+ onRepaired: report => { reportRepaired(ctx, sessionId, report, settings.mode); },
206
+ };
207
+ }
208
+ /**
209
+ * Record one unreadable `turn/end` reason.
210
+ *
211
+ * A warning rather than an error, and deliberately a different sentence from
212
+ * {@link reportNormalized}: this row was not a cause the entry guard rewrote but
213
+ * a cancellation the entry guard never saw, and the operator has to be able to
214
+ * tell the two apart when working out who produced it.
215
+ * @param ctx - the plugin context, for logging.
216
+ * @param sessionId - the session whose row it was.
217
+ * @param report - what the row carried, and what it became.
218
+ * @param mode - whether the row was repaired or passed through.
219
+ */
220
+ function reportRepaired(ctx, sessionId, report, mode) {
221
+ const outcome = mode === 'observe'
222
+ ? 'it was committed unchanged (mode "observe"), so this session is now unreadable in the chat view'
223
+ : 'it was recorded as '
224
+ + `{ "kind": "aborted", "reason": { "kind": "hook", "reason": ${JSON.stringify(report.normalized.reason)} } }`;
225
+ ctx.logger.warn(`${LOG_PREFIX} session ${sessionId}: a turn/end row for turn ${String(report.turn)} reached the `
226
+ + `log with a reason no reader can dereference (${report.described}); ${outcome}. `
227
+ + 'The cancellation never passed the entry guard, so its cause is not in this record: a caller '
228
+ + 'reached the cancel method through the class rather than through the instance, a second copy '
229
+ + 'of the loop aborted a signal of its own, or the instance refused the guard.');
230
+ }
141
231
  /**
142
232
  * Record one out-of-union cause.
143
233
  *
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The durable backstop: the `turn/end` row is never committed with a reason the
3
+ * conversation view cannot read.
4
+ *
5
+ * ## Why the cause guard alone is not enough
6
+ *
7
+ * The entry guard (`./guard.ts`) normalizes a cause before the loop ever sees it,
8
+ * and it covers every caller that goes through the **instance** method — which is
9
+ * every caller in the shipped tree. Two callers it cannot cover remain, and both
10
+ * end with the same unreadable row:
11
+ *
12
+ * - a caller that reaches the class rather than the instance
13
+ * (`ReactLoopAgent.prototype.cancel.call(agent, cause)`, or a second copy of the
14
+ * loop in the same process), which no observable hook hands over; and
15
+ * - an agent instance that refuses the own `cancel` property, where the entry
16
+ * guard reports `not-writable` and is simply absent.
17
+ *
18
+ * In both cases the out-of-union cause reaches `abortedCancelCause`'s
19
+ * `assertNever` **inside the turn's `catch`**, `turnEnds` stays at its `null`
20
+ * initializer, and the unconditional `finally` commits
21
+ * `{"type":"turn/end","data":{"turn":N,"reason":null}}`. The write path accepts it
22
+ * — `validateSessionEventData` inspects surface message slots, `request/header`
23
+ * and `tool/result`, and `turn/end.reason` is a TypeScript-only invariant — so
24
+ * the row is durable before any reader can object. The chat client then
25
+ * dereferences `reason.kind` while rendering and throws, which blanks the whole
26
+ * conversation area, for every session, across restarts.
27
+ *
28
+ * ## The seam
29
+ *
30
+ * `Session.append` is a **prototype** method of the session the agent holds, so an
31
+ * own-property write on `agent.session` shadows it for every writer — the loop's
32
+ * own `turn/start` / `step/start` / `turn/end` and any plugin's, alike. Only one
33
+ * event type is inspected; everything else is forwarded untouched.
34
+ *
35
+ * ## What it writes
36
+ *
37
+ * It does not invent a variant. A new `kind` would be a value the readers switch
38
+ * over have never seen, which trades a loud failure for a silent one — and
39
+ * `TurnEndReasonMap` is merge-extensible, so this plugin cannot even enumerate
40
+ * the members. It writes instead **the row the loop meant to write**: the turn was
41
+ * interrupted by a cancellation, and the cause came from outside the closed union,
42
+ * so it is `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }` —
43
+ * `aborted` is a member every reader knows, and `hook` is the member
44
+ * `AgentCancelCause` reserves for whoever outside the union cancelled the turn.
45
+ *
46
+ * The test is deliberately **dereferenceability, not membership**: a reason is
47
+ * left alone when it is a non-null object carrying a non-empty string `kind`,
48
+ * because a plugin may legitimately extend `TurnEndReasonMap` with a variant this
49
+ * plugin knows nothing about. What is refused is the shape no reader can read at
50
+ * all — `null`, absent, or not an object.
51
+ *
52
+ * @module @argszero/dsh-cancel-cause-guard/backstop
53
+ */
54
+ import type { BackstopHandle, BackstopOptions } from './types.ts';
55
+ /**
56
+ * The brand an installed append wrapper carries.
57
+ *
58
+ * `Symbol.for` for the reason {@link GUARD_MARK} uses it: two copies of this
59
+ * plugin in one process must recognise each other's work rather than stack.
60
+ */
61
+ export declare const APPEND_GUARD_MARK: unique symbol;
62
+ /**
63
+ * The reason text for a row whose reason carried nothing at all.
64
+ *
65
+ * Named for what the operator has to know: the cancellation did not pass the
66
+ * entry guard, so its cause was never seen and cannot be named.
67
+ */
68
+ export declare const UNSEEN_CAUSE_REASON = "a cancellation cause the entry guard never saw";
69
+ /**
70
+ * Whether a value is a reason a reader can dereference.
71
+ *
72
+ * The whole consequence of the defect is one expression — `reason.kind` — so the
73
+ * property this asks about is exactly that one: an object, not null, whose `kind`
74
+ * is non-empty text. Membership in `TurnEndReasonMap` is deliberately **not**
75
+ * asked, because the map is merge-extensible and a plugin's own variant would
76
+ * then be rewritten by a plugin that had never heard of it.
77
+ * @param reason - the value the loop is about to commit as `turn/end.reason`.
78
+ * @returns whether a reader may dereference `reason.kind`.
79
+ */
80
+ export declare function isReadableTurnEndReason(reason: unknown): boolean;
81
+ /**
82
+ * The reason to commit for a row whose own reason is unreadable.
83
+ *
84
+ * The configured reason wins when the operator set one; otherwise the text says
85
+ * the one thing that is certainly true — no entry guard saw this cancellation.
86
+ * @param options - the configured reason and its bound.
87
+ * @returns the reason text for the replacement row.
88
+ */
89
+ export declare function unseenCauseReason(options: {
90
+ reason?: string | undefined;
91
+ maxReasonChars: number;
92
+ }): string;
93
+ /**
94
+ * Shadow one session's `append` so an unreadable `turn/end` reason never lands.
95
+ *
96
+ * Every refusal is reported as an outcome rather than thrown: a session this
97
+ * plugin could not guard is a session that keeps the defect, not a reason to fail
98
+ * the lifecycle edge that delivered it.
99
+ * @param session - the live session the agent appends to.
100
+ * @param options - the mode, the reason override, the bound, and the report callback.
101
+ * @returns the outcome and an undo.
102
+ */
103
+ export declare function guardSessionAppend(session: unknown, options: BackstopOptions): BackstopHandle;
104
+ //# sourceMappingURL=backstop.d.ts.map
@@ -111,4 +111,12 @@ export declare function reasonText(value: unknown, maxChars?: number): string;
111
111
  * @returns a bounded, single-line rendering.
112
112
  */
113
113
  export declare function describeValue(value: unknown, maxChars?: number): string;
114
+ /**
115
+ * Bound one reason so a hostile value cannot write a wall of text into the log.
116
+ * Shared with the durable backstop, which mints a reason of its own.
117
+ * @param text - the reason text.
118
+ * @param maxChars - the bound, in characters.
119
+ * @returns the text, cut to the bound with a trailing ellipsis when it had to be.
120
+ */
121
+ export declare function truncate(text: string, maxChars: number): string;
114
122
  //# sourceMappingURL=cause.d.ts.map
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Configuration resolution for the cancellation-cause guard.
3
3
  *
4
- * The surface is deliberately three fields. A guard that rewrites a value on a
5
- * path as hot as cancellation has to be readable in one sitting, and every knob
6
- * it does not have is a way for an operator to be surprised by what it did.
4
+ * The surface is deliberately four fields. A guard that rewrites a value on
5
+ * paths as hot as cancellation and the session append has to be readable in one
6
+ * sitting, and every knob it does not have is a way for an operator to be
7
+ * surprised by what it did.
7
8
  *
8
9
  * An unknown key is refused rather than ignored: a misspelled `mode` that mounts
9
10
  * and guards nothing is how an operator believes a guard is in force when it is
@@ -33,9 +33,12 @@
33
33
  *
34
34
  * ## What this plugin does
35
35
  *
36
- * It listens to `agent/created` — the serial, awaited lifecycle edge that hands
37
- * over the **live agent instance** — and shadows that instance's `cancel` with a
38
- * wrapper that normalizes anything outside the union into
36
+ * It closes the defect at two depths, and the second one exists for the callers
37
+ * the first cannot see.
38
+ *
39
+ * **At the entry.** It listens to `agent/created` — the serial, awaited lifecycle
40
+ * edge that hands over the **live agent instance** — and shadows that instance's
41
+ * `cancel` with a wrapper that normalizes anything outside the union into
39
42
  * `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
40
43
  * it. `hook` is the union member the harness reserves for plugins, so the
41
44
  * rewrite is not a lie: a plugin did cancel the turn, and the reason names which
@@ -44,31 +47,42 @@
44
47
  * third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
45
48
  * the shadow is an own property.
46
49
  *
47
- * Every rewrite is logged, with the offending value rendered as JSON, so the
48
- * producer can be found and fixed rather than merely tolerated.
50
+ * **At the write.** The same edge hands over the **live session**, and the
51
+ * plugin shadows that session's own `append` too, so a `turn/end` row whose
52
+ * reason no reader can dereference is committed instead as the row the loop meant
53
+ * to write: `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }`. That
54
+ * arm is what covers the callers the entry guard cannot reach — one that goes
55
+ * through the class rather than the instance
56
+ * (`ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop
57
+ * in the process), and an instance that refuses the own `cancel` property, where
58
+ * the entry guard simply reports that it is not in force. Set `backstop: false`
59
+ * to leave the append path exactly as the harness's.
60
+ *
61
+ * Every rewrite and every repaired row is logged, with the offending value
62
+ * rendered as JSON, so the producer can be found and fixed rather than merely
63
+ * tolerated.
49
64
  *
50
65
  * ## What it does not do
51
66
  *
52
- * It does not refuse a cancellation, does not touch the options, does not
53
- * rewrite a cause that is already a member (that one is forwarded by reference),
54
- * and cannot repair a session that already carries a `null` row — those rows are
55
- * on disk, and this plugin reads no history. Mounting it bounds the defect from
56
- * that point on.
67
+ * It does not refuse a cancellation, does not touch the options, does not rewrite
68
+ * a cause that is already a member (that one is forwarded by reference), and
69
+ * cannot repair a session that already carries a `null` row — those rows are on
70
+ * disk, and this plugin reads no history. Mounting it bounds the defect from that
71
+ * point on.
57
72
  *
58
- * It also cannot cover the one caller that reaches the class rather than the
59
- * instance, which is why `mode: 'observe'` exists: an operator who wants to see
60
- * the failure happen may mount this plugin as a reporter and still get the
61
- * producer named in the log.
73
+ * `mode: 'observe'` exists for the operator who wants to see the failure happen:
74
+ * both arms report and neither rewrites, and the log names what arrived.
62
75
  *
63
76
  * @module @argszero/dsh-cancel-cause-guard
64
77
  */
65
78
  import type { Context } from '@deepseek-ai/cordis';
66
79
  import type { Config } from './types.ts';
67
80
  export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from './cause.ts';
81
+ export { APPEND_GUARD_MARK, UNSEEN_CAUSE_REASON, guardSessionAppend, isReadableTurnEndReason, unseenCauseReason } from './backstop.ts';
68
82
  export { DEFAULTS, resolveConfig } from './config.ts';
69
83
  export { GUARD_MARK, guardCancel } from './guard.ts';
70
84
  export type { GuardOptions } from './guard.ts';
71
- export type { Config, GuardHandle, GuardMode, GuardOutcome, NormalizedCause, ResolvedConfig, } from './types.ts';
85
+ export type { BackstopHandle, BackstopOptions, BackstopOutcome, Config, GuardHandle, GuardMode, GuardOutcome, NormalizedCause, RepairedTurnEnd, ResolvedConfig, } from './types.ts';
72
86
  /** Cordis plugin name, used by loader diagnostics. */
73
87
  export declare const name = "cancel-cause-guard";
74
88
  /**
@@ -27,12 +27,21 @@ export interface Config {
27
27
  readonly reason?: string | undefined;
28
28
  /** Bound on the reason text the guard mints. Defaults to 200. */
29
29
  readonly maxReasonChars?: number | undefined;
30
+ /**
31
+ * Whether the durable backstop is installed as well. Defaults to `true`.
32
+ *
33
+ * Set `false` to leave the session's own `append` untouched — for an operator
34
+ * who wants the write path to be exactly the harness's, and accepts that a
35
+ * cancellation which bypasses the entry guard can then blank a conversation.
36
+ */
37
+ readonly backstop?: boolean | undefined;
30
38
  }
31
39
  /** The configuration after defaults, validation and detaching. */
32
40
  export interface ResolvedConfig {
33
41
  readonly mode: GuardMode;
34
42
  readonly reason: string | undefined;
35
43
  readonly maxReasonChars: number;
44
+ readonly backstop: boolean;
36
45
  }
37
46
  /** What {@link guardCancel} found when it reached an agent. */
38
47
  export type GuardOutcome =
@@ -63,4 +72,45 @@ export interface GuardHandle {
63
72
  /** Restore the instance's own `cancel`, when this guard installed one. */
64
73
  readonly restore: () => void;
65
74
  }
75
+ /**
76
+ * What {@link guardSessionAppend} found when it reached a session.
77
+ *
78
+ * The four names mean what {@link GuardOutcome}'s do, for the other property: the
79
+ * session's `append` now refuses to commit an unreadable `turn/end` reason, some
80
+ * copy of this plugin is already in place, the session has no callable `append`,
81
+ * or it refused the own property.
82
+ */
83
+ export type BackstopOutcome = 'patched' | 'already-guarded' | 'no-append' | 'not-writable';
84
+ /** How one durable backstop is told to behave. */
85
+ export interface BackstopOptions {
86
+ /** What to do with an unreadable `turn/end` reason. */
87
+ readonly mode: GuardMode;
88
+ /** Reason text to record in place of a cause that was never seen. */
89
+ readonly reason?: string | undefined;
90
+ /** Bound on the reason text the backstop mints. */
91
+ readonly maxReasonChars: number;
92
+ /** Called once per repaired (or, in `observe` mode, merely reported) row. */
93
+ readonly onRepaired: (report: RepairedTurnEnd) => void;
94
+ }
95
+ /** One `turn/end` row the backstop repaired, as it was reported. */
96
+ export interface RepairedTurnEnd {
97
+ /** The turn number the row carried, for the log line. */
98
+ readonly turn: unknown;
99
+ /** The reason as it arrived — `null`, absent, or a value no reader can dereference. */
100
+ readonly original: unknown;
101
+ /** The `hook` member the replacement row carries. */
102
+ readonly normalized: {
103
+ readonly kind: 'hook';
104
+ readonly reason: string;
105
+ };
106
+ /** How the original was described for the log. */
107
+ readonly described: string;
108
+ }
109
+ /** The handle {@link guardSessionAppend} returns: how it went, and an undo. */
110
+ export interface BackstopHandle {
111
+ /** What happened when the backstop was installed. */
112
+ readonly outcome: BackstopOutcome;
113
+ /** Restore the session's own `append`, when this backstop installed one. */
114
+ readonly restore: () => void;
115
+ }
66
116
  //# sourceMappingURL=types.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@argszero/dsh-cancel-cause-guard",
3
- "description": "A plugin that cancels an agent's turn with a cause outside the closed AgentCancelCause union — a bare string like agent.cancel('thinking-loop'), or an object whose kind the union does not have — does not merely log something odd: it makes the whole conversation view unreadable. ReactLoopAgent.cancel hands the value to AbortController.abort(), so it becomes signal.reason; the turn's catch reads it back through abortedCancelCause, whose switch ends in assertNever *inside the catch body*, so turnEnds stays at its null initializer and the unconditional finally appends turn/end with that null (agent-loop/src/agent.ts:385, and the non-null assertion on the line is what makes it compile). Nothing on the write path refuses it — the append-time validator inspects surface slots, request/header and tool/result, and turn/end.reason is a TypeScript-only invariant. Every reader that dereferences it then throws, and the chat view does exactly that while rendering (ui-chat/src/client/contract/turn-process.ts:72), so one such row blanks the conversation area for every session. Reported three times (#8832, #9156, #9646). This plugin closes the seam that is left: it listens to agent/created — the serial, awaited lifecycle edge that hands over the live agent instance (packages/core/agent/src/index.ts:550) — and shadows that instance's cancel with a wrapper that carries anything outside the union as { kind: 'hook', reason } before the loop ever sees it. hook is the member the union reserves for plugins, so the rewrite is not a lie: a plugin did cancel the turn, and the reason names which one. One property write covers every instance-level caller, because ReactLoopAgent.cancel is a prototype method and the shadow is an own property. Every rewrite is logged with the offending value rendered as JSON, so the producer can be found and fixed rather than tolerated. It never refuses a cancellation, forwards an already-valid cause by reference, and cannot repair rows already on disk. Zero runtime dependencies beyond the harness packages it declares.",
4
- "version": "0.1.0",
3
+ "description": "A plugin that keeps one abusive cancellation from making a whole conversation view unreadable. ReactLoopAgent.cancel hands its argument to AbortController.abort(), so it becomes signal.reason; the turn's catch reads it back through abortedCancelCause, whose switch ends in assertNever *inside the catch body*, so turnEnds stays at its null initializer and the unconditional finally appends turn/end with that null (agent-loop/src/agent.ts:385 — the non-null assertion on that line is what makes it compile). Nothing on the write path refuses it: the append-time validator inspects surface slots, request/header and tool/result, and turn/end.reason is a TypeScript-only invariant. Every reader that dereferences it then throws, and the chat view does exactly that while rendering (ui-chat/src/client/contract/turn-process.ts:72), so one such row blanks the conversation area for every session, newly created ones included. Reported four times (#8832, #9156, #9646, #9699). This plugin closes the defect at two depths. At the entry: it listens to agent/created — the serial, awaited lifecycle edge that hands over the live agent instance (packages/core/agent/src/index.ts:550) — and shadows that instance's cancel with a wrapper that carries anything outside the union as { kind: 'hook', reason } before the loop ever sees it; hook is the member the union reserves for plugins, so the rewrite is not a lie, and one property write covers every instance-level caller because ReactLoopAgent.cancel is a prototype method and the shadow is an own property. At the write: the same edge hands over the live session, and the plugin shadows that session's append too, so a turn/end row whose reason no reader can dereference is committed as { kind: 'aborted', reason: { kind: 'hook', reason: <text> } } instead — the arm that covers a caller which reaches the class rather than the instance, or an instance that refuses the own cancel property. Every rewrite and every repaired row is logged with the offending value rendered as JSON, so the producer can be found and fixed rather than tolerated. It never refuses a cancellation, forwards an already-valid cause by reference, leaves any reason a reader can dereference untouched (a plugin's own TurnEndReasonMap variant included), and cannot repair rows already on disk. Zero runtime dependencies beyond the harness packages it declares.",
4
+ "version": "0.2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },