@north-light/crouter 0.3.302 → 0.3.304
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/dist/api/__tests__/integration/client.test.js +37 -36
- package/dist/api/client.d.ts +32 -55
- package/dist/api/client.js +102 -98
- package/dist/api/dto/messages.d.ts +6 -5
- package/dist/api/errors.d.ts +3 -0
- package/dist/api/errors.js +10 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/builtin-memory/internal/plugins.md +1 -1
- package/dist/clients/attach/viewer.js +532 -532
- package/dist/commands/__tests__/seam/daemon-status.test.d.ts +1 -0
- package/dist/commands/__tests__/seam/daemon-status.test.js +29 -0
- package/dist/commands/api-client.d.ts +3 -3
- package/dist/commands/api-client.js +3 -3
- package/dist/commands/memory/read.js +2 -1
- package/dist/commands/node/bash.js +6 -6
- package/dist/commands/node/message.js +3 -3
- package/dist/commands/sys/daemon.js +11 -13
- package/dist/core/__tests__/bash-guard.test.d.ts +1 -0
- package/dist/core/__tests__/bash-guard.test.js +190 -0
- package/dist/core/__tests__/broker-stream-watchdog-floor.test.js +0 -2
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +51 -11
- package/dist/core/__tests__/helpers/harness.d.ts +2 -0
- package/dist/core/__tests__/helpers/harness.js +9 -0
- package/dist/core/__tests__/integration/worktree-land.test.js +50 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +184 -5
- package/dist/core/__tests__/parse-argv-stdin-secret.test.js +14 -0
- package/dist/core/__tests__/seam/broker-attach-stream.test.js +13 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +57 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-startup-diagnostics.test.js +82 -0
- package/dist/core/bash-guard.d.ts +6 -0
- package/dist/core/bash-guard.js +393 -0
- package/dist/core/bash-jobs.d.ts +20 -8
- package/dist/core/bash-jobs.js +40 -21
- package/dist/core/canvas/types.d.ts +4 -2
- package/dist/core/command.js +1 -1
- package/dist/core/fault-classifier.d.ts +1 -1
- package/dist/core/runtime/broker/event-projection.d.ts +0 -3
- package/dist/core/runtime/broker/event-projection.js +2 -15
- package/dist/core/runtime/broker/fault-retry.d.ts +4 -0
- package/dist/core/runtime/broker/fault-retry.js +74 -6
- package/dist/core/runtime/broker-persona-guidance.js +12 -0
- package/dist/core/runtime/broker.js +0 -5
- package/dist/core/runtime/fault.js +1 -1
- package/dist/core/runtime/host.js +10 -1
- package/dist/core/runtime/spawn.js +5 -6
- package/dist/core/shell-segments.d.ts +42 -0
- package/dist/core/shell-segments.js +169 -0
- package/dist/core/substrate/surface-match.d.ts +0 -9
- package/dist/core/substrate/surface-match.js +4 -160
- package/dist/core/worktree-close.d.ts +4 -0
- package/dist/core/worktree-close.js +225 -0
- package/dist/core/worktree-containment.d.ts +14 -0
- package/dist/core/worktree-containment.js +48 -0
- package/dist/core/worktree-mutation-async.d.ts +13 -0
- package/dist/core/worktree-mutation-async.js +281 -0
- package/dist/core/worktree-sweep.js +17 -79
- package/dist/core/worktree.d.ts +1 -0
- package/dist/core/worktree.js +1 -1
- package/dist/daemon/__tests__/integration/api-startup-readiness.test.js +10 -8
- package/dist/daemon/api/__tests__/seam/api-server.test.js +70 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +7 -5
- package/dist/daemon/api/handlers/reports.js +3 -2
- package/dist/daemon/api/handlers/worktree.js +7 -6
- package/dist/daemon/fleet.d.ts +1 -1
- package/dist/daemon/fleet.js +30 -12
- package/dist/daemon/manage.d.ts +8 -5
- package/dist/daemon/manage.js +63 -40
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +12 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +1 -13
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +11 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +83 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/integration/canvas-bash-valve.test.js +133 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -3
- package/dist/pi-extensions/canvas-bash-valve.js +75 -77
- package/dist/pi-extensions/canvas-inbox-watcher.js +0 -2
- package/package.json +1 -1
- package/runtime.lock.json +5 -5
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// bash-guard.ts — the refusals the bash valve applies to a command's TEXT
|
|
2
|
+
// before any of it runs. Pure: command text and the node's cwd in, a refusal
|
|
3
|
+
// message or null out.
|
|
4
|
+
//
|
|
5
|
+
// Two refusals live here, for the same reason: both are things no correct
|
|
6
|
+
// agent command ever does, and both are cheaper to stop than to recover from.
|
|
7
|
+
//
|
|
8
|
+
// - A long leading `sleep` burns a live window on a clock the runtime already
|
|
9
|
+
// owns a cheaper primitive for.
|
|
10
|
+
// - A destructive command aimed at the home directory, a system root, or a
|
|
11
|
+
// directory above the node's own working directory is unrecoverable.
|
|
12
|
+
//
|
|
13
|
+
// The second exists because of a real incident. A node ran:
|
|
14
|
+
//
|
|
15
|
+
// cd star-theory-2 && HOME="$(mktemp -d …)" godot … ; rm -rf "$HOME" …
|
|
16
|
+
//
|
|
17
|
+
// A `VAR=value cmd` prefix applies only to that ONE command, so the trailing
|
|
18
|
+
// `rm -rf "$HOME"` used the real home directory and deleted for 108 seconds
|
|
19
|
+
// before another node killed it. A memory doc telling agents not to do this
|
|
20
|
+
// already exists; it is advisory and did not survive contact. This is the
|
|
21
|
+
// block.
|
|
22
|
+
//
|
|
23
|
+
// What this is NOT: a sandbox. Command text is read best-effort — expansions,
|
|
24
|
+
// subshells, `eval`, and a script the command invokes are all out of reach. It
|
|
25
|
+
// stops the plausible mistake a competent agent makes, not an agent trying to
|
|
26
|
+
// get around it. Anything it cannot resolve, it allows: a guard that guesses
|
|
27
|
+
// would refuse ordinary work, and an agent that learns the guard is noisy
|
|
28
|
+
// starts routing around it.
|
|
29
|
+
import { homedir, userInfo } from 'node:os';
|
|
30
|
+
import { isAbsolute, resolve, sep } from 'node:path';
|
|
31
|
+
import { commandSegments, stripHeredocs } from './shell-segments.js';
|
|
32
|
+
// Long-sleep guard. A command that opens with `sleep <long>` is a node burning
|
|
33
|
+
// a live window to wait on a clock — the runtime already has waiting primitives
|
|
34
|
+
// that cost nothing (`crtr cron` for a scheduled wake, `crtr node wait deadline`
|
|
35
|
+
// for an inbox-vs-deadline race). Anything over 10s is refused before it runs.
|
|
36
|
+
const MAX_INLINE_SLEEP_SECONDS = 10;
|
|
37
|
+
const SLEEP_UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
38
|
+
/** Seconds a leading `sleep ...` will block for, or null when the command does
|
|
39
|
+
* not open with a sleep (or its duration isn't statically knowable). */
|
|
40
|
+
export function leadingSleepSeconds(command) {
|
|
41
|
+
const match = /^\s*sleep(\s+[^;&|\n]+)/.exec(command);
|
|
42
|
+
if (!match)
|
|
43
|
+
return null;
|
|
44
|
+
// `sleep 1 2m 3` sums its operands.
|
|
45
|
+
let total = 0;
|
|
46
|
+
for (const operand of match[1].trim().split(/\s+/)) {
|
|
47
|
+
const parsed = /^([0-9]*\.?[0-9]+)([smhd]?)$/.exec(operand);
|
|
48
|
+
if (!parsed)
|
|
49
|
+
return null; // variable/expansion/flag — not statically knowable
|
|
50
|
+
total += Number(parsed[1]) * SLEEP_UNIT_SECONDS[parsed[2] || 's'];
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
function longSleepRefusal(command) {
|
|
55
|
+
const seconds = leadingSleepSeconds(command);
|
|
56
|
+
if (seconds === null || seconds <= MAX_INLINE_SLEEP_SECONDS)
|
|
57
|
+
return null;
|
|
58
|
+
return (`Refused: this command starts by sleeping ${seconds}s, over the ${MAX_INLINE_SLEEP_SECONDS}s inline limit.\n` +
|
|
59
|
+
`Holding a live window on a clock is never how you wait here. Instead:\n` +
|
|
60
|
+
` - schedule the wake and end your turn: crtr cron -h (one-shot at a time, or recurring)\n` +
|
|
61
|
+
` - race an inbox message against a deadline: crtr node wait deadline -h\n` +
|
|
62
|
+
` - waiting on a child, a reply from the user, or any spine event: just stop — the runtime wakes you.\n` +
|
|
63
|
+
`If you truly need a short pause inline, sleep ${MAX_INLINE_SLEEP_SECONDS}s or less.`);
|
|
64
|
+
}
|
|
65
|
+
// Protected paths
|
|
66
|
+
/** System directories whose removal, relocation, or recursive permission
|
|
67
|
+
* change breaks the machine rather than the task. Matched EXACTLY after
|
|
68
|
+
* resolution — `/tmp` is protected, `/tmp/build-1234` is ordinary work. */
|
|
69
|
+
const PROTECTED_SYSTEM_PATHS = [
|
|
70
|
+
'/',
|
|
71
|
+
'/Applications',
|
|
72
|
+
'/bin',
|
|
73
|
+
'/dev',
|
|
74
|
+
'/etc',
|
|
75
|
+
'/home',
|
|
76
|
+
'/Library',
|
|
77
|
+
'/opt',
|
|
78
|
+
'/private',
|
|
79
|
+
'/private/etc',
|
|
80
|
+
'/private/tmp',
|
|
81
|
+
'/private/var',
|
|
82
|
+
'/sbin',
|
|
83
|
+
'/System',
|
|
84
|
+
'/tmp',
|
|
85
|
+
'/usr',
|
|
86
|
+
'/Users',
|
|
87
|
+
'/var',
|
|
88
|
+
'/Volumes',
|
|
89
|
+
];
|
|
90
|
+
/** Why a resolved target is protected, in the words the refusal uses. `null`
|
|
91
|
+
* when the target is ordinary and the command may run. */
|
|
92
|
+
function protectedReason(target, home, cwd) {
|
|
93
|
+
if (PROTECTED_SYSTEM_PATHS.includes(target))
|
|
94
|
+
return 'a system directory';
|
|
95
|
+
if (target === home)
|
|
96
|
+
return 'your home directory';
|
|
97
|
+
if (target === `${home}/.crouter`)
|
|
98
|
+
return 'the canvas home — every node\u2019s conversation and state';
|
|
99
|
+
// Strict ancestors only: a node may delete its OWN working directory (a
|
|
100
|
+
// merged worktree is a real case), just nothing that contains it.
|
|
101
|
+
if (cwd === target || !cwd.startsWith(target.endsWith(sep) ? target : target + sep))
|
|
102
|
+
return null;
|
|
103
|
+
return `a directory containing your working directory (${cwd})`;
|
|
104
|
+
}
|
|
105
|
+
function initialVariables(home, cwd) {
|
|
106
|
+
const user = userInfo().username;
|
|
107
|
+
return new Map([['HOME', home], ['PWD', cwd], ['USER', user], ['LOGNAME', user]]);
|
|
108
|
+
}
|
|
109
|
+
/** The text with `~` and every known `$VAR` substituted, or null when what is
|
|
110
|
+
* left still depends on something the guard cannot see. */
|
|
111
|
+
function expandText(text, vars) {
|
|
112
|
+
let t = text;
|
|
113
|
+
if (t === '~' || t.startsWith('~/')) {
|
|
114
|
+
const home = vars.get('HOME');
|
|
115
|
+
if (home === undefined)
|
|
116
|
+
return null;
|
|
117
|
+
t = home + t.slice(1);
|
|
118
|
+
}
|
|
119
|
+
else if (t.startsWith('~')) {
|
|
120
|
+
return null; // ~otheruser — not ours to resolve
|
|
121
|
+
}
|
|
122
|
+
t = t.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced, bare) => vars.get(braced ?? bare) ?? whole);
|
|
123
|
+
// Anything still carrying an expansion or a glob is not statically knowable.
|
|
124
|
+
return /[$`*?[]/.test(t) ? null : t;
|
|
125
|
+
}
|
|
126
|
+
/** The absolute path an operand names, or null when it is not statically
|
|
127
|
+
* knowable. A trailing `/*` or `/.` is stripped first: `rm -rf ~/*` empties
|
|
128
|
+
* the same tree as `rm -rf ~`. A relative operand resolves against `base` —
|
|
129
|
+
* null when an earlier `cd` in the same line went somewhere the guard cannot
|
|
130
|
+
* follow. */
|
|
131
|
+
function resolveTarget(text, vars, base) {
|
|
132
|
+
const expanded = expandText(text.replace(/\/+\*?\.?$/, '') || '/', vars);
|
|
133
|
+
if (expanded === null)
|
|
134
|
+
return null;
|
|
135
|
+
if (isAbsolute(expanded))
|
|
136
|
+
return resolve(expanded);
|
|
137
|
+
return base === null ? null : resolve(base, expanded);
|
|
138
|
+
}
|
|
139
|
+
/** Fold `VAR=value` assignments into a variable map. A value the guard cannot
|
|
140
|
+
* expand deletes the variable rather than leaving a stale one behind: reading
|
|
141
|
+
* a later `$HOME` as the real home when the line reassigned it is exactly the
|
|
142
|
+
* false refusal that would teach agents to route around this guard. */
|
|
143
|
+
function applyAssignments(vars, assignments) {
|
|
144
|
+
for (const assignment of assignments) {
|
|
145
|
+
const eq = assignment.indexOf('=');
|
|
146
|
+
if (eq <= 0)
|
|
147
|
+
continue;
|
|
148
|
+
const name = assignment.slice(0, eq);
|
|
149
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
150
|
+
continue;
|
|
151
|
+
const value = expandText(assignment.slice(eq + 1), vars);
|
|
152
|
+
if (value === null)
|
|
153
|
+
vars.delete(name);
|
|
154
|
+
else
|
|
155
|
+
vars.set(name, value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// The destructive operations
|
|
159
|
+
/** A redirection, whole (`2>/dev/null`) or as its bare operator (`>`), which
|
|
160
|
+
* takes the next word as its file. Neither is an operand of the command. */
|
|
161
|
+
const REDIRECTION = /^[0-9]*(&?>>?|<<?|>&|<&)/;
|
|
162
|
+
/** Operands of a segment, minus flags, the `--` end-of-flags marker, and
|
|
163
|
+
* redirections. */
|
|
164
|
+
function operands(tokens) {
|
|
165
|
+
const out = [];
|
|
166
|
+
let flagsEnded = false;
|
|
167
|
+
let skipNext = false;
|
|
168
|
+
for (const token of tokens.slice(1)) {
|
|
169
|
+
if (skipNext) {
|
|
170
|
+
skipNext = false;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const redirection = REDIRECTION.exec(token.text);
|
|
174
|
+
if (redirection !== null && !token.opaque) {
|
|
175
|
+
skipNext = token.text === redirection[0]; // bare operator — its file is the next word
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (!flagsEnded && token.text === '--') {
|
|
179
|
+
flagsEnded = true;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!flagsEnded && token.text.startsWith('-') && token.text !== '-')
|
|
183
|
+
continue;
|
|
184
|
+
out.push(token.text);
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
/** Short and long flags of a segment, short combinations split into letters. */
|
|
189
|
+
function flagLetters(tokens) {
|
|
190
|
+
const letters = new Set();
|
|
191
|
+
const long = new Set();
|
|
192
|
+
for (const token of tokens.slice(1)) {
|
|
193
|
+
if (token.text === '--')
|
|
194
|
+
break;
|
|
195
|
+
if (token.text.startsWith('--'))
|
|
196
|
+
long.add(token.text.slice(2));
|
|
197
|
+
else if (token.text.startsWith('-') && token.text.length > 1)
|
|
198
|
+
for (const ch of token.text.slice(1))
|
|
199
|
+
letters.add(ch);
|
|
200
|
+
}
|
|
201
|
+
return { letters, long };
|
|
202
|
+
}
|
|
203
|
+
function isRecursive(tokens) {
|
|
204
|
+
const { letters, long } = flagLetters(tokens);
|
|
205
|
+
return letters.has('r') || letters.has('R') || long.has('recursive');
|
|
206
|
+
}
|
|
207
|
+
/** A block device holding a filesystem. `/dev/null`, `/dev/stdout`, and
|
|
208
|
+
* `/dev/zero` are ordinary output; a disk node is not. */
|
|
209
|
+
const RAW_DISK_DEVICE = /^\/dev\/r?(disk|sd[a-z]|nvme|hd[a-z]|vd[a-z]|mmcblk|md)/;
|
|
210
|
+
/** `find`'s start paths: the words between its leading global options and the
|
|
211
|
+
* first predicate of the expression. Those options are why the paths cannot
|
|
212
|
+
* simply be "everything before the first `-` word" — `find -H ~ -delete` and
|
|
213
|
+
* `find -- ~ -delete` both walk the home tree. */
|
|
214
|
+
function findPaths(tokens) {
|
|
215
|
+
const paths = [];
|
|
216
|
+
let i = 1;
|
|
217
|
+
while (i < tokens.length) {
|
|
218
|
+
const text = tokens[i].text;
|
|
219
|
+
if (text === '--' || /^-[HLPEdsx]$/.test(text))
|
|
220
|
+
i += 1;
|
|
221
|
+
else if (text === '-D' || text === '-O')
|
|
222
|
+
i += 2; // GNU debug/optimisation, each with a value
|
|
223
|
+
else if (text === '-f') { // BSD's explicit start path
|
|
224
|
+
if (i + 1 < tokens.length)
|
|
225
|
+
paths.push(tokens[i + 1].text);
|
|
226
|
+
i += 2;
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
for (; i < tokens.length; i += 1) {
|
|
232
|
+
if (tokens[i].text.startsWith('-'))
|
|
233
|
+
break;
|
|
234
|
+
paths.push(tokens[i].text);
|
|
235
|
+
}
|
|
236
|
+
return paths;
|
|
237
|
+
}
|
|
238
|
+
/** Command names that stand in front of the real command and must be stepped
|
|
239
|
+
* over before it can be read. Their own flags are skipped with them. */
|
|
240
|
+
const COMMAND_PREFIXES = new Set(['sudo', 'doas', 'command', 'env', 'nice', 'nohup', 'time', 'stdbuf', 'xargs', 'exec', 'builtin']);
|
|
241
|
+
/** Strip every leading prefix command and its flags, leaving the real command
|
|
242
|
+
* as `tokens[0]`. `sudo -E nice -n 10 rm -rf ~` reads as `rm -rf ~`. */
|
|
243
|
+
function stripCommandPrefixes(tokens) {
|
|
244
|
+
let rest = tokens;
|
|
245
|
+
while (rest.length > 0 && !rest[0].opaque && COMMAND_PREFIXES.has(rest[0].text)) {
|
|
246
|
+
let i = 1;
|
|
247
|
+
while (i < rest.length && rest[i].text.startsWith('-')) {
|
|
248
|
+
// `nice -n 10` and `env -u VAR` take a value; a lone flag does not.
|
|
249
|
+
const takesValue = /^-[nu]$/.test(rest[i].text);
|
|
250
|
+
i += takesValue ? 2 : 1;
|
|
251
|
+
}
|
|
252
|
+
// `env FOO=bar cmd` — the assignments belong to env, not to the command.
|
|
253
|
+
while (i < rest.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[i].text))
|
|
254
|
+
i += 1;
|
|
255
|
+
if (i >= rest.length)
|
|
256
|
+
return [];
|
|
257
|
+
rest = rest.slice(i);
|
|
258
|
+
}
|
|
259
|
+
return rest;
|
|
260
|
+
}
|
|
261
|
+
/** Targets this segment would destroy: the operands whose loss the guard
|
|
262
|
+
* cares about, or `[]` when the segment is not a destructive operation. A
|
|
263
|
+
* verb appears here only when ONE invocation of it can take out a whole tree.
|
|
264
|
+
* A device or subcommand named directly (`mkfs.ext4`, `diskutil eraseDisk`)
|
|
265
|
+
* has no path operand to resolve, so it reports itself. */
|
|
266
|
+
function destructiveTargets(tokens) {
|
|
267
|
+
const none = { targets: [], named: null };
|
|
268
|
+
const head = tokens[0];
|
|
269
|
+
if (head === undefined || head.opaque)
|
|
270
|
+
return none;
|
|
271
|
+
const name = head.text.slice(head.text.lastIndexOf('/') + 1);
|
|
272
|
+
const args = operands(tokens);
|
|
273
|
+
switch (name) {
|
|
274
|
+
// `rm` without a recursive flag cannot remove a directory at all.
|
|
275
|
+
case 'rm':
|
|
276
|
+
return isRecursive(tokens) ? { targets: args, named: null } : none;
|
|
277
|
+
// Every operand but the destination is a source that stops existing.
|
|
278
|
+
case 'mv':
|
|
279
|
+
return args.length >= 2 ? { targets: args.slice(0, -1), named: null } : none;
|
|
280
|
+
// A recursive mode or ownership sweep over a system tree is as final as a
|
|
281
|
+
// deletion. The first operand is the mode/owner, not a path.
|
|
282
|
+
case 'chmod':
|
|
283
|
+
case 'chown':
|
|
284
|
+
case 'chgrp':
|
|
285
|
+
return isRecursive(tokens) && args.length >= 2 ? { targets: args.slice(1), named: null } : none;
|
|
286
|
+
// `find <path> … -delete` and `-exec` walk the tree and act on every hit.
|
|
287
|
+
case 'find': {
|
|
288
|
+
const acts = tokens.some((t) => !t.opaque && (t.text === '-delete' || t.text === '-exec' || t.text === '-execdir'));
|
|
289
|
+
return acts ? { targets: findPaths(tokens), named: null } : none;
|
|
290
|
+
}
|
|
291
|
+
// `dd of=…` overwrites its output in place; a raw disk is unrecoverable,
|
|
292
|
+
// while `of=/dev/null` and friends are ordinary output redirection.
|
|
293
|
+
case 'dd': {
|
|
294
|
+
const of = tokens.find((t) => t.text.startsWith('of='));
|
|
295
|
+
if (of === undefined)
|
|
296
|
+
return none;
|
|
297
|
+
const path = of.text.slice(3);
|
|
298
|
+
if (RAW_DISK_DEVICE.test(path))
|
|
299
|
+
return { targets: [], named: path };
|
|
300
|
+
return path.startsWith('/dev/') ? none : { targets: [path], named: null };
|
|
301
|
+
}
|
|
302
|
+
default:
|
|
303
|
+
// Filesystem creation and disk erasure destroy a volume outright; there
|
|
304
|
+
// is no safe target for them inside an agent's task.
|
|
305
|
+
if (/^(mkfs|newfs)([.-].+)?$/.test(name))
|
|
306
|
+
return { targets: [], named: name };
|
|
307
|
+
if (name === 'diskutil') {
|
|
308
|
+
const verb = args[0];
|
|
309
|
+
if (verb !== undefined && /^(erase|reformat|partition|zeroDisk|randomDisk|secureErase)/i.test(verb)) {
|
|
310
|
+
return { targets: [], named: `diskutil ${verb}` };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return none;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** Walk the line segment by segment, carrying the variable values and working
|
|
317
|
+
* directory the shell itself would carry, and refuse the first destructive
|
|
318
|
+
* segment aimed at a protected path.
|
|
319
|
+
*
|
|
320
|
+
* Carrying that state IS the guard's core distinction. `export HOME=/tmp/x`
|
|
321
|
+
* as its own command changes `$HOME` for everything after it, so a later
|
|
322
|
+
* `rm -rf "$HOME"` is a scratch directory and runs. A `HOME=/tmp/x cmd`
|
|
323
|
+
* PREFIX applies to that one command only, so a later `rm -rf "$HOME"` is the
|
|
324
|
+
* real home — which is precisely the mistake that destroyed one. */
|
|
325
|
+
function destructiveCommandRefusal(command, cwd, home) {
|
|
326
|
+
const vars = initialVariables(home, cwd);
|
|
327
|
+
// Every directory a relative operand might be resolved from. A `cd` ADDS its
|
|
328
|
+
// destination rather than replacing the previous one, because the guard
|
|
329
|
+
// cannot know whether the shell's `cd` succeeded: after `cd missing-dir`,
|
|
330
|
+
// bash stays put and `rm -rf ..` really is the ancestor. Refusing when ANY
|
|
331
|
+
// candidate is protected keeps that case caught; the refusal names the
|
|
332
|
+
// resolved path, and an absolute operand is never ambiguous.
|
|
333
|
+
let bases = [cwd];
|
|
334
|
+
for (const segment of commandSegments(stripHeredocs(command))) {
|
|
335
|
+
const tokens = stripCommandPrefixes(segment.tokens);
|
|
336
|
+
const head = tokens[0];
|
|
337
|
+
if (head !== undefined) {
|
|
338
|
+
// This segment's own prefix assignments apply to this command only.
|
|
339
|
+
const local = new Map(vars);
|
|
340
|
+
applyAssignments(local, segment.assignments);
|
|
341
|
+
const innermost = bases[bases.length - 1];
|
|
342
|
+
if (innermost !== undefined)
|
|
343
|
+
local.set('PWD', innermost);
|
|
344
|
+
const { targets, named } = destructiveTargets(tokens);
|
|
345
|
+
if (named !== null)
|
|
346
|
+
return refusalMessage(segment.text, named, 'a whole disk or volume — never a task boundary');
|
|
347
|
+
for (const target of targets) {
|
|
348
|
+
for (const base of bases.length === 0 ? [null] : bases) {
|
|
349
|
+
const resolved = resolveTarget(target, local, base);
|
|
350
|
+
if (resolved === null)
|
|
351
|
+
continue;
|
|
352
|
+
const reason = protectedReason(resolved, home, cwd);
|
|
353
|
+
if (reason !== null)
|
|
354
|
+
return refusalMessage(segment.text, resolved, reason);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// Then carry this segment's lasting effects into the rest of the line. A
|
|
359
|
+
// backgrounded segment runs in a subshell, so it has none.
|
|
360
|
+
if (segment.background)
|
|
361
|
+
continue;
|
|
362
|
+
if (head === undefined)
|
|
363
|
+
applyAssignments(vars, segment.assignments);
|
|
364
|
+
else if (!head.opaque && SHELL_VARIABLE_BUILTINS.has(head.text)) {
|
|
365
|
+
// A prefix assignment to a special builtin persists in the shell.
|
|
366
|
+
applyAssignments(vars, segment.assignments);
|
|
367
|
+
applyAssignments(vars, operands(tokens));
|
|
368
|
+
}
|
|
369
|
+
else if (!head.opaque && head.text === 'cd') {
|
|
370
|
+
const destination = operands(tokens)[0];
|
|
371
|
+
const next = destination === undefined ? vars.get('HOME') ?? null : resolveTarget(destination, vars, bases[bases.length - 1] ?? null);
|
|
372
|
+
// An unfollowable `cd` leaves no knowable base at all; a relative operand
|
|
373
|
+
// after it is unresolvable, and unresolvable always means allowed.
|
|
374
|
+
bases = next === null ? [] : [...bases, next];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
/** Builtins that set shell variables for everything after them. */
|
|
380
|
+
const SHELL_VARIABLE_BUILTINS = new Set(['export', 'declare', 'typeset', 'readonly']);
|
|
381
|
+
function refusalMessage(segment, target, reason) {
|
|
382
|
+
return (`Refused: \`${segment}\` targets ${target}, which is ${reason}.\n` +
|
|
383
|
+
`Nothing an agent is asked to do requires destroying that path, and it cannot be undone.\n` +
|
|
384
|
+
`If you meant somewhere else, name that path explicitly and run the command again. Two traps to check first:\n` +
|
|
385
|
+
` - a \`VAR=value cmd\` prefix applies ONLY to that one command, so a later \`$VAR\` in the same line is the SHELL's value — this is exactly how a node deleted a home directory.\n` +
|
|
386
|
+
` - \`~\` and \`$HOME\` are the real home directory, never a scratch dir; build scratch paths with mktemp -d and hold them in a variable of your own.\n` +
|
|
387
|
+
`If you believe this path genuinely must be destroyed, ask the user — do not work around this refusal.`);
|
|
388
|
+
}
|
|
389
|
+
/** The refusal this command earns before it runs, or null when it may run.
|
|
390
|
+
* `cwd` is the directory the command will execute in. */
|
|
391
|
+
export function bashCommandRefusal(command, cwd, home = homedir()) {
|
|
392
|
+
return longSleepRefusal(command) ?? destructiveCommandRefusal(command, cwd, home);
|
|
393
|
+
}
|
package/dist/core/bash-jobs.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export interface BashJobPaths {
|
|
|
12
12
|
* Inspector's cancel) — without it on disk, only the agent's own handoff
|
|
13
13
|
* notice knows the group to signal. */
|
|
14
14
|
jobPgid: string;
|
|
15
|
+
/** Launch-time identity of the detached supervisor, used to refuse a PID that
|
|
16
|
+
* has since been reused by an unrelated process. */
|
|
17
|
+
jobPgidIdentity: string;
|
|
15
18
|
/** Human-readable label supplied with the bash call, when any. */
|
|
16
19
|
jobPurpose: string;
|
|
17
20
|
/** Epoch-ms cancel deadline for an AUTO-backgrounded job. Present only when
|
|
@@ -33,6 +36,9 @@ export declare function bashJobPaths(contextDir: string, jobId: string): BashJob
|
|
|
33
36
|
/** The job's supervisor process group, or undefined for a job started before
|
|
34
37
|
* pgid was persisted (or a partially-written dir). */
|
|
35
38
|
export declare function readJobPgid(contextDir: string, jobId: string): number | undefined;
|
|
39
|
+
/** Launch-time identity of the supervisor. Missing for jobs created before
|
|
40
|
+
* process-identity persistence, which are deliberately not safe to signal. */
|
|
41
|
+
export declare function readJobPgidIdentity(contextDir: string, jobId: string): string | undefined;
|
|
36
42
|
/** The optional human-readable job label. Jobs created before labels existed
|
|
37
43
|
* have no file and deliberately project null. */
|
|
38
44
|
export declare function readJobPurpose(contextDir: string, jobId: string): string | null;
|
|
@@ -73,6 +79,12 @@ export declare function clearJobDeadline(paths: BashJobPaths): void;
|
|
|
73
79
|
* means the group exists but cannot be signaled by this process; it is still
|
|
74
80
|
* live for the status surface. */
|
|
75
81
|
export declare function isBashJobProcessGroupAlive(pgid: number): boolean;
|
|
82
|
+
/** Best-effort cancellation for a detached bash supervisor. It snapshots the
|
|
83
|
+
* observable descendant tree while the supervisor is still alive, signals its
|
|
84
|
+
* leaves before their parents, then uses the shared identity-guarded KILL
|
|
85
|
+
* escalation. A descendant that detached and reparented before this snapshot
|
|
86
|
+
* is outside this guarantee. */
|
|
87
|
+
export declare function cancelBashJobProcessTree(pgid: number, expectedIdentity: string): Promise<boolean>;
|
|
76
88
|
export type StopBackgroundBashJobResult = {
|
|
77
89
|
kind: 'stopped';
|
|
78
90
|
signaled: boolean;
|
|
@@ -87,15 +99,15 @@ export type StopBackgroundBashJobResult = {
|
|
|
87
99
|
kind: 'not-stoppable';
|
|
88
100
|
logPath: string;
|
|
89
101
|
};
|
|
90
|
-
/** Stop one live background job using the same
|
|
91
|
-
*
|
|
92
|
-
*
|
|
102
|
+
/** Stop one live background job using the same best-effort tree cancellation
|
|
103
|
+
* semantics as inline abort and timeout. The caller owns the user-facing
|
|
104
|
+
* notification; this primitive owns only the job control-plane mutation.
|
|
93
105
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
106
|
+
* Only a job in the live-background state the roster projects (job.bg present,
|
|
107
|
+
* job.exit absent) may be signaled. Job directories persist after the
|
|
108
|
+
* supervisor exits, so a retired, still-foreground, or already-stopped job
|
|
109
|
+
* would otherwise have its recorded pgid signaled long after the OS could have
|
|
110
|
+
* reused that id for an unrelated process group. */
|
|
99
111
|
export declare function stopBackgroundBashJob(contextDir: string, jobId: string): Promise<StopBackgroundBashJobResult>;
|
|
100
112
|
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
101
113
|
* rather than the whole file, which for a long-running job can be enormous. */
|
package/dist/core/bash-jobs.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { randomBytes } from 'node:crypto';
|
|
6
6
|
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
|
+
import { captureTeardownSnapshot, isAnyPidAlive, killProcessTreePids } from './canvas/pid.js';
|
|
8
9
|
const MAX_BASH_JOB_PURPOSE_BYTES = 280;
|
|
9
10
|
/** A purpose is display text, never shell input. Keep the file-backed control
|
|
10
11
|
* plane to one bounded, visible line so old or hand-written job directories
|
|
@@ -41,6 +42,7 @@ export function bashJobPaths(contextDir, jobId) {
|
|
|
41
42
|
jobBg: join(dir, 'job.bg'),
|
|
42
43
|
jobDone: join(dir, 'job.done'),
|
|
43
44
|
jobPgid: join(dir, 'job.pgid'),
|
|
45
|
+
jobPgidIdentity: join(dir, 'job.pgid-identity'),
|
|
44
46
|
jobPurpose: join(dir, 'purpose'),
|
|
45
47
|
jobDeadline: join(dir, 'job.deadline'),
|
|
46
48
|
};
|
|
@@ -56,6 +58,17 @@ export function readJobPgid(contextDir, jobId) {
|
|
|
56
58
|
return undefined;
|
|
57
59
|
}
|
|
58
60
|
}
|
|
61
|
+
/** Launch-time identity of the supervisor. Missing for jobs created before
|
|
62
|
+
* process-identity persistence, which are deliberately not safe to signal. */
|
|
63
|
+
export function readJobPgidIdentity(contextDir, jobId) {
|
|
64
|
+
try {
|
|
65
|
+
const identity = readFileSync(bashJobPaths(contextDir, jobId).jobPgidIdentity, 'utf8').trim();
|
|
66
|
+
return identity === '' ? undefined : identity;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
59
72
|
/** The optional human-readable job label. Jobs created before labels existed
|
|
60
73
|
* have no file and deliberately project null. */
|
|
61
74
|
export function readJobPurpose(contextDir, jobId) {
|
|
@@ -143,15 +156,31 @@ export function isBashJobProcessGroupAlive(pgid) {
|
|
|
143
156
|
return err.code === 'EPERM';
|
|
144
157
|
}
|
|
145
158
|
}
|
|
146
|
-
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
159
|
+
/** Best-effort cancellation for a detached bash supervisor. It snapshots the
|
|
160
|
+
* observable descendant tree while the supervisor is still alive, signals its
|
|
161
|
+
* leaves before their parents, then uses the shared identity-guarded KILL
|
|
162
|
+
* escalation. A descendant that detached and reparented before this snapshot
|
|
163
|
+
* is outside this guarantee. */
|
|
164
|
+
export async function cancelBashJobProcessTree(pgid, expectedIdentity) {
|
|
165
|
+
const snapshot = captureTeardownSnapshot(pgid, expectedIdentity);
|
|
166
|
+
if (snapshot.reused || snapshot.tree.length === 0)
|
|
167
|
+
return false;
|
|
168
|
+
const tree = [...snapshot.tree].reverse();
|
|
169
|
+
const signaled = isAnyPidAlive(tree);
|
|
170
|
+
killProcessTreePids(tree, 'SIGTERM', snapshot.identities);
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
172
|
+
killProcessTreePids(tree, 'SIGKILL', snapshot.identities);
|
|
173
|
+
return signaled;
|
|
174
|
+
}
|
|
175
|
+
/** Stop one live background job using the same best-effort tree cancellation
|
|
176
|
+
* semantics as inline abort and timeout. The caller owns the user-facing
|
|
177
|
+
* notification; this primitive owns only the job control-plane mutation.
|
|
149
178
|
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
179
|
+
* Only a job in the live-background state the roster projects (job.bg present,
|
|
180
|
+
* job.exit absent) may be signaled. Job directories persist after the
|
|
181
|
+
* supervisor exits, so a retired, still-foreground, or already-stopped job
|
|
182
|
+
* would otherwise have its recorded pgid signaled long after the OS could have
|
|
183
|
+
* reused that id for an unrelated process group. */
|
|
155
184
|
export async function stopBackgroundBashJob(contextDir, jobId) {
|
|
156
185
|
const paths = bashJobPaths(contextDir, jobId);
|
|
157
186
|
if (!existsSync(paths.dir))
|
|
@@ -159,7 +188,8 @@ export async function stopBackgroundBashJob(contextDir, jobId) {
|
|
|
159
188
|
if (!existsSync(paths.jobBg) || existsSync(paths.jobExit))
|
|
160
189
|
return { kind: 'retired', logPath: paths.jobLog };
|
|
161
190
|
const pgid = readJobPgid(contextDir, jobId);
|
|
162
|
-
|
|
191
|
+
const pgidIdentity = readJobPgidIdentity(contextDir, jobId);
|
|
192
|
+
if (pgid === undefined || pgidIdentity === undefined)
|
|
163
193
|
return { kind: 'not-stoppable', logPath: paths.jobLog };
|
|
164
194
|
// Claim the stop by creating job.exit exclusively, before signaling. The
|
|
165
195
|
// loser of two concurrent stops — and a stop that races the supervisor's own
|
|
@@ -179,18 +209,7 @@ export async function stopBackgroundBashJob(contextDir, jobId) {
|
|
|
179
209
|
return { kind: 'retired', logPath: paths.jobLog };
|
|
180
210
|
throw err;
|
|
181
211
|
}
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
process.kill(-pgid, 'SIGTERM');
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
signaled = false;
|
|
188
|
-
}
|
|
189
|
-
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
190
|
-
try {
|
|
191
|
-
process.kill(-pgid, 'SIGKILL');
|
|
192
|
-
}
|
|
193
|
-
catch { /* already gone */ }
|
|
212
|
+
const signaled = await cancelBashJobProcessTree(pgid, pgidIdentity);
|
|
194
213
|
return { kind: 'stopped', signaled, pgid, logPath: paths.jobLog };
|
|
195
214
|
}
|
|
196
215
|
/** Last `count` lines of a job log, cheaply: read at most the trailing 64 KiB
|
|
@@ -76,11 +76,13 @@ export interface LaunchSpec {
|
|
|
76
76
|
env: Record<string, string>;
|
|
77
77
|
}
|
|
78
78
|
/** The repository fingerprint a refusal was decided against. An examination
|
|
79
|
-
* that reads the same
|
|
80
|
-
*
|
|
79
|
+
* that reads the same values can skip the whole proof cascade: nothing about
|
|
80
|
+
* the refusal can have changed. */
|
|
81
81
|
export interface ManagedWorktreeObservation {
|
|
82
82
|
/** Tip of `refs/heads/<base_ref>`, or null when it no longer resolves. */
|
|
83
83
|
base: string | null;
|
|
84
|
+
/** Tip of the base branch's upstream (or `origin/<base_ref>` fallback). */
|
|
85
|
+
upstream: string | null;
|
|
84
86
|
/** Tip of `refs/heads/<branch>`, or null when the branch is gone. */
|
|
85
87
|
branch: string | null;
|
|
86
88
|
/** `absent`, or a hash of the checkout's HEAD plus `git status --porcelain`. */
|
package/dist/core/command.js
CHANGED
|
@@ -403,7 +403,7 @@ export async function parseArgv(params, tokens, options) {
|
|
|
403
403
|
continue;
|
|
404
404
|
}
|
|
405
405
|
const rawVal = inlineValue !== undefined ? inlineValue : tokens[++i];
|
|
406
|
-
if (rawVal === undefined || rawVal.startsWith('--')) {
|
|
406
|
+
if (rawVal === undefined || (inlineValue === undefined && rawVal.startsWith('--'))) {
|
|
407
407
|
const focusedNext = flagDef.focusedHelp !== undefined && (options?.leafPath?.length ?? 0) > 0
|
|
408
408
|
? `Run \`crtr ${options.leafPath.join(' ')} --${flagName} -h\` and read the focused value contract before retrying.`
|
|
409
409
|
: undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ErrorClass, OperationId } from './events/types.js';
|
|
2
|
-
export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'viewer↔crtrd' | 'daemon→node' | 'crtr→pi';
|
|
2
|
+
export type FaultLink = 'pi→provider' | 'viewer↔broker' | 'relay↔broker' | 'viewer↔crtrd' | 'broker↔crtrd' | 'daemon→node' | 'crtr→pi';
|
|
3
3
|
export type FaultKind = 'rate-limit' | 'overloaded' | 'connection' | 'auth' | 'protocol' | 'context-overflow' | 'other' | 'wedged' | 'model-not-found';
|
|
4
4
|
export type FaultRetryDisposition = 'auto' | 'manual' | 'fatal';
|
|
5
5
|
export type FaultRetryOwner = 'sdk' | 'daemon' | 'client';
|
|
@@ -11,8 +11,6 @@ type EventProjectionDeps = {
|
|
|
11
11
|
notifyTurnAccepted: () => void;
|
|
12
12
|
emitStartupMilestone: (event: string, model: string | null) => void;
|
|
13
13
|
formatModelSpec: (model: BrokerSession['model'], thinkingLevel?: string) => string | null;
|
|
14
|
-
refreshIntent: () => Promise<boolean>;
|
|
15
|
-
onRefreshIntentError: (error: unknown) => void;
|
|
16
14
|
};
|
|
17
15
|
/** Projects one live engine event stream to broker viewers and owns its relay timers. */
|
|
18
16
|
export declare class EventProjection {
|
|
@@ -40,7 +38,6 @@ export declare class EventProjection {
|
|
|
40
38
|
private flushPendingUpdate;
|
|
41
39
|
private invalidatePendingBroadcasts;
|
|
42
40
|
private broadcast;
|
|
43
|
-
private broadcastMessageEnd;
|
|
44
41
|
private setPendingBroadcast;
|
|
45
42
|
}
|
|
46
43
|
export {};
|
|
@@ -128,8 +128,8 @@ export class EventProjection {
|
|
|
128
128
|
return;
|
|
129
129
|
}
|
|
130
130
|
this.flushPendingUpdate();
|
|
131
|
-
if (type === 'message_end') {
|
|
132
|
-
this.
|
|
131
|
+
if (type === 'message_end' && generation.stagedRefreshAbort) {
|
|
132
|
+
this.broadcast({ ...event, message: withoutYieldAbort(event.message) });
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
this.broadcast(event);
|
|
@@ -183,19 +183,6 @@ export class EventProjection {
|
|
|
183
183
|
this.deps.registry.broadcast(frame);
|
|
184
184
|
}));
|
|
185
185
|
}
|
|
186
|
-
broadcastMessageEnd(event) {
|
|
187
|
-
const preceding = this.pendingBroadcast ?? Promise.resolve();
|
|
188
|
-
const generation = this.broadcastGeneration;
|
|
189
|
-
this.setPendingBroadcast(preceding.then(async () => {
|
|
190
|
-
const refresh = await this.deps.refreshIntent();
|
|
191
|
-
if (this.broadcastGeneration !== generation)
|
|
192
|
-
return;
|
|
193
|
-
this.deps.registry.broadcast(refresh ? { ...event, message: withoutYieldAbort(event.message) } : event);
|
|
194
|
-
}).catch((error) => {
|
|
195
|
-
if (this.broadcastGeneration === generation)
|
|
196
|
-
this.deps.onRefreshIntentError(error);
|
|
197
|
-
}));
|
|
198
|
-
}
|
|
199
186
|
setPendingBroadcast(work) {
|
|
200
187
|
this.pendingBroadcast = work;
|
|
201
188
|
void work.then(() => {
|
|
@@ -25,6 +25,7 @@ export declare class FaultRetry {
|
|
|
25
25
|
private readonly notFoundModels;
|
|
26
26
|
private providerFallbackInFlight;
|
|
27
27
|
private notFoundFallbackInFlight;
|
|
28
|
+
private stagedCapacityFailure;
|
|
28
29
|
constructor(deps: FaultRetryDeps);
|
|
29
30
|
clearTimer(): void;
|
|
30
31
|
reset(): void;
|
|
@@ -54,6 +55,9 @@ export declare class FaultRetry {
|
|
|
54
55
|
schedule(): void;
|
|
55
56
|
private maybeSwitchProviderForRetry;
|
|
56
57
|
private maybeFallbackOnUnusableModel;
|
|
58
|
+
private nextEligibleFallbackRoute;
|
|
59
|
+
private targetCannotFitContext;
|
|
60
|
+
private stageCapacityFailure;
|
|
57
61
|
private recordPendingProviderFault;
|
|
58
62
|
private sessionFile;
|
|
59
63
|
private pendingEpisodeMatches;
|