@argszero/dsh-cancel-cause-guard 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +277 -2
- package/cordis.patch.yml +72 -0
- package/lib/backstop.js +222 -0
- package/lib/cause.js +205 -0
- package/lib/config.js +65 -0
- package/lib/guard.js +139 -0
- package/lib/index.js +251 -0
- package/lib/types/backstop.d.ts +104 -0
- package/lib/types/cause.d.ts +122 -0
- package/lib/types/config.d.ts +26 -0
- package/lib/types/guard.d.ts +80 -0
- package/lib/types/index.d.ts +115 -0
- package/lib/types/types.d.ts +116 -0
- package/lib/types.js +6 -0
- package/package.json +72 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 argszero
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,278 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @argszero/dsh-cancel-cause-guard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A prevention guard for the one `turn/end` row that makes a whole conversation view
|
|
4
|
+
unreadable.
|
|
5
|
+
|
|
6
|
+
The harness ships an `Agent.cancel(cause)` whose argument is a **closed** union —
|
|
7
|
+
`{ kind: 'user' }`, `{ kind: 'parent' }`, `{ kind: 'hook', reason }`,
|
|
8
|
+
`{ kind: 'disposed' }` — and that union is enforced by TypeScript alone. Nothing on
|
|
9
|
+
the write path refuses a value outside it, and one such value does not merely log
|
|
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, and
|
|
12
|
+
backstops the session's own `append` so that even a cancellation it never gets to
|
|
13
|
+
see cannot commit an unreadable row.
|
|
14
|
+
|
|
15
|
+
## The defect
|
|
16
|
+
|
|
17
|
+
`ReactLoopAgent.cancel` passes its argument straight to `AbortController.abort()`, so
|
|
18
|
+
the cause becomes `signal.reason`. The turn's `catch` reads it back through
|
|
19
|
+
`abortedCancelCause` (`dsh-agent-loop/src/agent.ts:80`):
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const cause = signal.reason as AgentCancelCause
|
|
23
|
+
switch (cause.kind) {
|
|
24
|
+
case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
|
|
25
|
+
case 'hook': return { kind: 'hook', reason: cause.reason }
|
|
26
|
+
default: return assertNever(cause)
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That function is called **inside the `catch`**. A value outside the union therefore
|
|
31
|
+
throws before the catch body assigns `turnEnds`, which is still at its `null`
|
|
32
|
+
initializer, and the `finally` appends unconditionally:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
let turnEnds: TurnEndReason | null = null // agent.ts:312
|
|
36
|
+
...
|
|
37
|
+
} finally {
|
|
38
|
+
this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:387
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The non-null assertion on that line is what makes it compile, and the row lands as
|
|
43
|
+
`{"type":"turn/end","data":{"turn":N,"reason":null}}`. Nothing rejects it on the way:
|
|
44
|
+
the append-time validator inspects surface message slots, `request/header` and
|
|
45
|
+
`tool/result`, so `reason` is a TypeScript-only invariant. Every reader that
|
|
46
|
+
dereferences it then throws — the chat client does `location.turn.end?.data.reason.kind`
|
|
47
|
+
while rendering — and one such row blanks the entire conversation area, for every
|
|
48
|
+
session, including newly created ones.
|
|
49
|
+
|
|
50
|
+
Reported four times: discussions **#8832**, **#9156**, **#9646** and **#9699**.
|
|
51
|
+
|
|
52
|
+
### The version boundary
|
|
53
|
+
|
|
54
|
+
A harness older than `0.1.7` had no such function. The turn's `catch` wrote
|
|
55
|
+
`{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse produced a
|
|
56
|
+
malformed-but-readable row rather than a `null` one. From `0.1.7-rc.2` on, the same
|
|
57
|
+
abuse is fatal — which is why this is a **guard** and not a report: the shape of the
|
|
58
|
+
failure changed under callers that had not changed at all.
|
|
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
|
+
|
|
75
|
+
## What it does
|
|
76
|
+
|
|
77
|
+
### 1. Normalize the cause at the entry
|
|
78
|
+
|
|
79
|
+
It listens to `agent/created` — the serial, awaited lifecycle edge that hands over the
|
|
80
|
+
**live agent instance** (`dsh-agent/src/index.ts:550`) — and shadows that instance's
|
|
81
|
+
`cancel` with a wrapper that normalizes anything outside the union into
|
|
82
|
+
`{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees it.
|
|
83
|
+
|
|
84
|
+
- **`hook` is the right member, not a convenient one.** It is the member the union
|
|
85
|
+
reserves for plugins: a plugin did cancel the turn, and the reason names which one.
|
|
86
|
+
- **One property write covers every instance-level caller.** `ReactLoopAgent.cancel`
|
|
87
|
+
is a prototype method, so an **own** property of that name shadows it for the loop's
|
|
88
|
+
own `agent.cancel({ kind: 'user' })`, the chat client's stop button, and any
|
|
89
|
+
third-party plugin alike.
|
|
90
|
+
- **A valid cause is forwarded by reference.** The object the loop reads back out of
|
|
91
|
+
the signal is the object the caller passed; nothing is copied and nothing is
|
|
92
|
+
reported.
|
|
93
|
+
- **A cancellation is never refused.** The wrapper normalizes, forwards and reports,
|
|
94
|
+
in that order, and touches nothing but the cause — a plugin that stopped a stop
|
|
95
|
+
would be worse than the defect.
|
|
96
|
+
- **Two shapes are covered, and only one of them looks like a mistake.** An object
|
|
97
|
+
whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`), and a value that
|
|
98
|
+
is not an object at all — most often `undefined`, from a caller whose own
|
|
99
|
+
declaration made the argument optional, where `cause.kind` throws one line earlier.
|
|
100
|
+
- **The wrapper binds the original to the agent it came from**, so a caller holding a
|
|
101
|
+
detached `const cancel = agent.cancel` still reaches the method with the receiver
|
|
102
|
+
its body requires.
|
|
103
|
+
|
|
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.
|
|
133
|
+
|
|
134
|
+
## Install
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
npm install @argszero/dsh-cancel-cause-guard
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Then mount it from your profile's bundle patch (`dsh.bundle.patch`):
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
- insert:
|
|
144
|
+
- id: cancel-cause-guard
|
|
145
|
+
name: '@argszero/dsh-cancel-cause-guard'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Or, if you compose `cordis.yml` by hand:
|
|
149
|
+
|
|
150
|
+
```yaml
|
|
151
|
+
- set:
|
|
152
|
+
- id: cancel-cause-guard
|
|
153
|
+
name: '@argszero/dsh-cancel-cause-guard'
|
|
154
|
+
config:
|
|
155
|
+
mode: normalize
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Configuration
|
|
159
|
+
|
|
160
|
+
| Key | Default | Meaning |
|
|
161
|
+
|---|---|---|
|
|
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. |
|
|
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. |
|
|
166
|
+
|
|
167
|
+
A configuration the plugin cannot honour is refused **at mount** rather than quietly
|
|
168
|
+
replaced: an unknown key, a mode this plugin does not have, a `reason` that would name
|
|
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.
|
|
172
|
+
|
|
173
|
+
## Diagnostics
|
|
174
|
+
|
|
175
|
+
One warning per rewritten (or, in `observe` mode, merely reported) cause:
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
cancel-cause-guard: session <id>: a caller passed "thinking-loop" as a cancellation
|
|
179
|
+
cause, which is not a member of AgentCancelCause; the turn was recorded as
|
|
180
|
+
{ "kind": "aborted", "reason": { "kind": "hook", "reason": "thinking-loop" } }.
|
|
181
|
+
Which plugin passes it is the value itself: the reason carries its own name.
|
|
182
|
+
```
|
|
183
|
+
|
|
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.
|
|
200
|
+
|
|
201
|
+
Four refusals are warned about at the moment they matter, because a guard that is
|
|
202
|
+
silently absent is the thing an operator cannot see:
|
|
203
|
+
|
|
204
|
+
- an agent that carries no `cancel` method at all;
|
|
205
|
+
- an agent that refuses an own `cancel` property (a sealed or non-writable instance),
|
|
206
|
+
where the install is confirmed by **reading the property back**, not by the absence
|
|
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.
|
|
210
|
+
|
|
211
|
+
## What it is not
|
|
212
|
+
|
|
213
|
+
- **It cannot repair a session that already carries a `null` row.** Those rows are on
|
|
214
|
+
disk and this plugin reads no history. Mounting it bounds the defect from that point
|
|
215
|
+
on; the sessions that already have one stay unreadable until the row is removed.
|
|
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.
|
|
219
|
+
- **It calls no model and needs no service.** `inject` is empty: the whole seam is the
|
|
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.
|
|
223
|
+
|
|
224
|
+
## Evidence
|
|
225
|
+
|
|
226
|
+
- `test/lifecycle.spec.mjs` — the plugin mounted through a real Cordis context, against
|
|
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.
|
|
243
|
+
- `test/cause.spec.mjs` — the pure half: what each shape becomes, what it is called, the
|
|
244
|
+
bound, and the values that must never throw inside `cancel`.
|
|
245
|
+
- `test/config.spec.mjs` — the defaults, detaching, and the refusals.
|
|
246
|
+
- `scripts/witness-cancel-cause.mjs` — the consequence, proved against the **published**
|
|
247
|
+
`@deepseek-ai/dsh-agent-loop` sources rather than a stand-in: it extracts
|
|
248
|
+
`abortedCancelCause` and `assertNever` from the shipped tarball, shows a bare-string
|
|
249
|
+
cause throwing `unreachable variant: "thinking-loop"` unguarded and returning
|
|
250
|
+
`{ kind: 'hook', reason: 'thinking-loop' }` guarded, and records the `0.1.6-alpha.2`
|
|
251
|
+
boundary where no such function exists. Run it with
|
|
252
|
+
`npm run witness:cancel-cause`.
|
|
253
|
+
- `npm run test:inject` — mutates the source, rebuilds, and requires each mutation to be
|
|
254
|
+
caught by the suite. An arm whose mutation leaves the suite green proves nothing about
|
|
255
|
+
the test that was supposed to hold the line, and is reported as `SILENT`.
|
|
256
|
+
- `npm run probe:lines` — runs the suite once per released line, each with every harness
|
|
257
|
+
package pinned to that line. That measurement is where the supported-lines list below
|
|
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`.
|
|
261
|
+
|
|
262
|
+
## Supported lines
|
|
263
|
+
|
|
264
|
+
The suite is measured green against each of these, with `peerDependencies` restricted to
|
|
265
|
+
the same three:
|
|
266
|
+
|
|
267
|
+
| Harness line | Cordis series |
|
|
268
|
+
|---|---|
|
|
269
|
+
| `0.1.7-rc.2` | `4.0.4` |
|
|
270
|
+
| `0.2.0-rc.2` | `4.0.4` |
|
|
271
|
+
| `0.2.1-alpha.2` | `~4.0.5-alpha.1` |
|
|
272
|
+
|
|
273
|
+
Earlier generations are **not** covered and are not claimed: `0.1.6-alpha.2` and below
|
|
274
|
+
have no `abortedCancelCause`, so there is nothing for this plugin to prevent there.
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# The @argszero/dsh-cancel-cause-guard bundle patch: mount and go.
|
|
2
|
+
#
|
|
3
|
+
# One `turn/end` row can make a whole conversation view unreadable. When a caller
|
|
4
|
+
# cancels a turn with a value outside the closed AgentCancelCause union — a bare
|
|
5
|
+
# string such as `agent.cancel('thinking-loop')`, or an object whose `kind` the
|
|
6
|
+
# union does not have — ReactLoopAgent.cancel hands it to `AbortController.abort()`,
|
|
7
|
+
# so it becomes `signal.reason`. The turn's `catch` reads it back through
|
|
8
|
+
# `abortedCancelCause`, whose switch ends in `assertNever` **inside the catch body**:
|
|
9
|
+
# the assignment never runs, `turnEnds` stays at its `null` initializer, and the
|
|
10
|
+
# unconditional `finally` appends `turn/end` with that `null`
|
|
11
|
+
# (`dsh-agent-loop/src/agent.ts:385`, and the non-null assertion on the line is what
|
|
12
|
+
# makes it compile).
|
|
13
|
+
#
|
|
14
|
+
# Nothing on the write path refuses the row — the append-time validator inspects
|
|
15
|
+
# surface message slots, `request/header` and `tool/result`, and `turn/end.reason` is
|
|
16
|
+
# a TypeScript-only invariant. Every reader that dereferences it then throws, and the
|
|
17
|
+
# chat client does exactly that while rendering, so one such row blanks the
|
|
18
|
+
# conversation area. Reported four times: discussions #8832, #9156, #9646 and #9699.
|
|
19
|
+
#
|
|
20
|
+
# A harness older than `0.1.7` had no such function and recorded the same abuse as a
|
|
21
|
+
# malformed-but-readable row, which is why this is a guard: the failure changed shape
|
|
22
|
+
# under callers that had not changed at all.
|
|
23
|
+
#
|
|
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
|
+
#
|
|
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.
|
|
45
|
+
#
|
|
46
|
+
# Optional config:
|
|
47
|
+
#
|
|
48
|
+
# - set:
|
|
49
|
+
# - id: cancel-cause-guard
|
|
50
|
+
# config:
|
|
51
|
+
# # 'normalize' (default) rewrites an out-of-union cause to
|
|
52
|
+
# # { kind: 'hook', reason }. 'observe' passes the caller's value through
|
|
53
|
+
# # untouched — the turn fails exactly as it would have — and only records
|
|
54
|
+
# # the producer in the log.
|
|
55
|
+
# mode: normalize
|
|
56
|
+
# # Reason text to record in place of the offending value's own name.
|
|
57
|
+
# # Absent (the default) names the cause after the value that produced it,
|
|
58
|
+
# # which is the only handle on whoever produced it.
|
|
59
|
+
# # reason: plugin-bug
|
|
60
|
+
# # Bound on the minted reason, in characters. Defaults to 200.
|
|
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
|
|
66
|
+
#
|
|
67
|
+
# An unknown key is refused rather than ignored: a misspelled `mode` that mounts and
|
|
68
|
+
# guards nothing is how an operator believes a guard is in force when it is not.
|
|
69
|
+
|
|
70
|
+
- insert:
|
|
71
|
+
- id: cancel-cause-guard
|
|
72
|
+
name: '@argszero/dsh-cancel-cause-guard'
|
package/lib/backstop.js
ADDED
|
@@ -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
|
+
}
|