@commonlyai/cli 0.1.11 → 0.1.15
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/package.json +2 -2
- package/src/commands/agent.js +96 -11
- package/src/lib/enforcement.js +176 -18
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
|
-
"description": "The Commonly CLI
|
|
5
|
+
"description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./src/index.js",
|
|
8
8
|
"bin": {
|
package/src/commands/agent.js
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
} from '../lib/spawn-retry.js';
|
|
42
42
|
import {
|
|
43
43
|
ADDRESSED_EVENT_TYPES,
|
|
44
|
+
CASCADE_DEFAULTS,
|
|
45
|
+
CASCADE_ENV_VARS,
|
|
44
46
|
CLAIMABLE_EVENT_TYPES,
|
|
45
47
|
classifyTrigger,
|
|
46
48
|
createCascadeGovernor,
|
|
@@ -48,6 +50,7 @@ import {
|
|
|
48
50
|
createClaimKeeper,
|
|
49
51
|
deliverChatReply,
|
|
50
52
|
peerHoldsFrame,
|
|
53
|
+
resolveCascadeSettings,
|
|
51
54
|
} from '../lib/enforcement.js';
|
|
52
55
|
|
|
53
56
|
// ── Token file I/O — ~/.commonly/tokens/<name>.json (ADR-005) ───────────────
|
|
@@ -709,9 +712,12 @@ export const performRun = ({
|
|
|
709
712
|
clearIntervalImpl = clearInterval,
|
|
710
713
|
retryJitterRatio,
|
|
711
714
|
claimLeaseSeconds = 90,
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
+
// Undefined means "not overridden" — resolveCascadeSettings falls back to
|
|
716
|
+
// the env var, then to the shipped default. Passing a value here still
|
|
717
|
+
// wins, which is how the tests pin a cap of 1.
|
|
718
|
+
cascadeCap,
|
|
719
|
+
cascadeAddressedGrace,
|
|
720
|
+
cascadeResetMs,
|
|
715
721
|
chatCharLimit = 400,
|
|
716
722
|
maxChatChunks = 3,
|
|
717
723
|
claimYieldDelayMs = 3000,
|
|
@@ -730,11 +736,27 @@ export const performRun = ({
|
|
|
730
736
|
const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
|
|
731
737
|
// Per-seat cascade state — lives with the process, like the session store.
|
|
732
738
|
// A wrapper restart forgets the streak; the decay window covers that gap.
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
739
|
+
const cascadeSettings = resolveCascadeSettings({
|
|
740
|
+
overrides: {
|
|
741
|
+
cap: cascadeCap,
|
|
742
|
+
addressedGrace: cascadeAddressedGrace,
|
|
743
|
+
resetMs: cascadeResetMs,
|
|
744
|
+
},
|
|
745
|
+
warn: (message) => log(`cascade config: ${message}`),
|
|
737
746
|
});
|
|
747
|
+
const cascadeGovernor = createCascadeGovernor(cascadeSettings);
|
|
748
|
+
// Unconditional, because the only other cascade line on this path is the
|
|
749
|
+
// warn callback above — which fires only when a value is BAD. Without this,
|
|
750
|
+
// a seat running COMMONLY_CASCADE_CAP=8 logs exactly what a seat on the
|
|
751
|
+
// shipped default logs, and an env var (unlike the source edit it replaces)
|
|
752
|
+
// leaves no git evidence of which seat diverged. The `(defaults)` marker
|
|
753
|
+
// makes "show me every retuned seat" a grep for its absence.
|
|
754
|
+
const cascadeIsDefault = Object.keys(CASCADE_DEFAULTS)
|
|
755
|
+
.every((key) => cascadeSettings[key] === CASCADE_DEFAULTS[key]);
|
|
756
|
+
log(
|
|
757
|
+
`cascade: cap=${cascadeSettings.cap} grace=${cascadeSettings.addressedGrace} `
|
|
758
|
+
+ `reset=${cascadeSettings.resetMs}ms${cascadeIsDefault ? ' (defaults)' : ''}`,
|
|
759
|
+
);
|
|
738
760
|
// Fairness: recent broadcast-race winners start the next race from the back.
|
|
739
761
|
const claimHandicap = createClaimHandicap({ delayMs: claimYieldDelayMs });
|
|
740
762
|
|
|
@@ -837,15 +859,57 @@ export const performRun = ({
|
|
|
837
859
|
// Recording a human trigger sets the streak to 0, so this call and the
|
|
838
860
|
// completion-time one are idempotent with each other.
|
|
839
861
|
if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
|
|
840
|
-
const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
|
|
862
|
+
const admission = cascadeGovernor.admit(eventPodId, trigger, event.type, event.payload);
|
|
841
863
|
if (!admission.allowed) {
|
|
864
|
+
// Name the MESSAGE, not just the pod. Without this the refusal is silent at
|
|
865
|
+
// three ends, not two: the mentioning agent gets no signal its mention died,
|
|
866
|
+
// an operator reading the log cannot tell which mentions were dropped, and
|
|
867
|
+
// the suppressed seat cannot say what it ignored — it never saw the id. Two
|
|
868
|
+
// real mentions were lost this way on 2026-08-19 and the seat they were
|
|
869
|
+
// addressed to could not enumerate them afterwards.
|
|
842
870
|
log(
|
|
843
871
|
`[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered `
|
|
844
872
|
+ `turns in pod ${eventPodId}`
|
|
845
|
-
+ (
|
|
846
|
-
+
|
|
873
|
+
+ (event?.payload?.messageId ? ` (dropped message ${event.payload.messageId})` : '')
|
|
874
|
+
+ (admission.graceApplied ? ' (addressed grace also spent)' : '')
|
|
875
|
+
// NOT "until the pod goes quiet": other seats' traffic never touches
|
|
876
|
+
// this seat's clock, and a refusal returns here without reaching
|
|
877
|
+
// `record()` below — so the window is measured from this seat's last
|
|
878
|
+
// ADMITTED turn in this pod and runs regardless of how loud the room
|
|
879
|
+
// is. Saying "the pod goes quiet" told an operator to wait for a lull
|
|
880
|
+
// that is neither necessary nor sufficient (#989).
|
|
881
|
+
+ ` — standing down until a human speaks or ${cascadeSettings.resetMs}ms `
|
|
882
|
+
+ 'passes with no admitted turn from this seat in this pod',
|
|
847
883
|
);
|
|
848
|
-
|
|
884
|
+
// `details` rides through to the AgentEvent row untouched:
|
|
885
|
+
// normalizeDeliveryMeta whitelists outcome/reason/messageId and passes
|
|
886
|
+
// `details` straight into `delivery.details`, typed Schema.Types.Mixed.
|
|
887
|
+
// So the settings that produced this refusal become queryable with NO
|
|
888
|
+
// backend change and no deploy.
|
|
889
|
+
//
|
|
890
|
+
// This is the half the boot log cannot cover. The log says what a seat
|
|
891
|
+
// resolved; it does not survive a restart, and nothing joins it to the
|
|
892
|
+
// refusals it caused. Once cap is per-seat and arbitrary, a refusal
|
|
893
|
+
// whose cap is unknown cannot be compared against one from another seat.
|
|
894
|
+
// `reason` deliberately stays the fixed literal 'cascade-cap' — :1111
|
|
895
|
+
// matches it with === and the run-loop tests assert it — so the value
|
|
896
|
+
// goes in a sibling field instead of being spelled into the reason.
|
|
897
|
+
return {
|
|
898
|
+
outcome: 'no_action',
|
|
899
|
+
reason: 'cascade-cap',
|
|
900
|
+
details: {
|
|
901
|
+
// The id of what was dropped. `reason` and `streak` say a refusal
|
|
902
|
+
// happened; only this says WHICH message never got answered, which is
|
|
903
|
+
// what anyone auditing a missed mention actually needs to join on.
|
|
904
|
+
messageId: event?.payload?.messageId || null,
|
|
905
|
+
streak: admission.streak,
|
|
906
|
+
cap: cascadeSettings.cap,
|
|
907
|
+
addressedGrace: cascadeSettings.addressedGrace,
|
|
908
|
+
resetMs: cascadeSettings.resetMs,
|
|
909
|
+
addressed: admission.addressed,
|
|
910
|
+
graceApplied: admission.graceApplied,
|
|
911
|
+
},
|
|
912
|
+
};
|
|
849
913
|
}
|
|
850
914
|
|
|
851
915
|
// ── ADR-018 enforcement: claim-before-act ───────────────────────────────
|
|
@@ -1404,6 +1468,23 @@ export const performDetach = async ({
|
|
|
1404
1468
|
*/
|
|
1405
1469
|
const stamp = () => new Date().toISOString();
|
|
1406
1470
|
|
|
1471
|
+
/**
|
|
1472
|
+
* Map `agent run`'s cascade flags onto resolveCascadeSettings' override keys.
|
|
1473
|
+
*
|
|
1474
|
+
* Exported because the mapping is where the flag path can go wrong invisibly:
|
|
1475
|
+
* an earlier version coerced with Number() here, so `--cascade-cap abc`
|
|
1476
|
+
* reached the resolver as NaN and the warning read `--cascade-cap='NaN'` —
|
|
1477
|
+
* naming a value the user never typed, on the one line whose whole job is to
|
|
1478
|
+
* tell them which input was bad. The values pass through RAW; the resolver is
|
|
1479
|
+
* the only thing that parses, for the same reason it is the only thing that
|
|
1480
|
+
* validates.
|
|
1481
|
+
*/
|
|
1482
|
+
export const cascadeOverridesFromOpts = (opts = {}) => ({
|
|
1483
|
+
cascadeCap: opts.cascadeCap,
|
|
1484
|
+
cascadeAddressedGrace: opts.cascadeGrace,
|
|
1485
|
+
cascadeResetMs: opts.cascadeReset,
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1407
1488
|
export const registerAgent = (program) => {
|
|
1408
1489
|
const agent = program.command('agent').description('Manage agents');
|
|
1409
1490
|
|
|
@@ -1744,6 +1825,9 @@ Docs:
|
|
|
1744
1825
|
.description('Run the local-CLI wrapper loop for an attached agent')
|
|
1745
1826
|
.option('--interval <ms>', 'Poll interval in ms', '5000')
|
|
1746
1827
|
.option('--adapter <name>', 'CLI to wrap on first-run bootstrap (claude|codex); ignored when a token file already exists')
|
|
1828
|
+
.option('--cascade-cap <n>', `Consecutive agent-triggered turns allowed per pod (env ${CASCADE_ENV_VARS.cap}, default ${CASCADE_DEFAULTS.cap})`)
|
|
1829
|
+
.option('--cascade-grace <n>', `Extra turns allowed when this seat was directly addressed; 0 disables the grace (env ${CASCADE_ENV_VARS.addressedGrace}, default ${CASCADE_DEFAULTS.addressedGrace})`)
|
|
1830
|
+
.option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
|
|
1747
1831
|
.action(async (name, opts) => {
|
|
1748
1832
|
let record = loadAgentToken(name);
|
|
1749
1833
|
if (!record) {
|
|
@@ -1797,6 +1881,7 @@ Docs:
|
|
|
1797
1881
|
environment: record.environment || null,
|
|
1798
1882
|
workspacePath: record.workspacePath || null,
|
|
1799
1883
|
intervalMs: parseInt(opts.interval, 10),
|
|
1884
|
+
...cascadeOverridesFromOpts(opts),
|
|
1800
1885
|
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
1801
1886
|
// Both sinks are stamped, and both must be — but not for the reason
|
|
1802
1887
|
// this comment gave until now, which its own PR falsified.
|
package/src/lib/enforcement.js
CHANGED
|
@@ -45,6 +45,14 @@ export const CLAIMABLE_EVENT_TYPES = new Set([
|
|
|
45
45
|
*/
|
|
46
46
|
export const classifyTrigger = (event, recentMessages) => {
|
|
47
47
|
const p = event?.payload || {};
|
|
48
|
+
// The third pricing branch (#1044, fable 55845). Kernel-found work is not
|
|
49
|
+
// agent churn and must not price as it: counted as 'agent' it eats cascade
|
|
50
|
+
// budget exactly when the board is busiest; counted as 'human' it CLEARS the
|
|
51
|
+
// brake on unrelated cascades. 'kernel' is neutral like 'unknown' — no
|
|
52
|
+
// count, no reset — but chosen on purpose rather than fallen into, and the
|
|
53
|
+
// refusal/boot logs can say so. Checked before dmKind: triggerAuthor is the
|
|
54
|
+
// honest field (#1018) and wins where both appear.
|
|
55
|
+
if (p.triggerAuthor === 'kernel') return 'kernel';
|
|
48
56
|
if (p.dmKind === 'agent-agent') return 'agent';
|
|
49
57
|
if (p.dmKind === 'user-agent') return 'human';
|
|
50
58
|
if (!p.messageId || !Array.isArray(recentMessages)) return 'unknown';
|
|
@@ -62,8 +70,114 @@ export const classifyTrigger = (event, recentMessages) => {
|
|
|
62
70
|
// race is a free stand-down.
|
|
63
71
|
export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
|
|
64
72
|
|
|
73
|
+
// These are the two event types the kernel's bot-to-bot mention dampener
|
|
74
|
+
// bounds as one shared budget. Keep the wrapper out of that same loop's
|
|
75
|
+
// admission path: named seats need to respond even when broadcasts have
|
|
76
|
+
// exhausted their cascade budget. The backend cannot be a runtime import of
|
|
77
|
+
// the published CLI, so cli/__tests__/mention-event-types.contract.test.mjs
|
|
78
|
+
// imports both modules and pins the two lists together.
|
|
79
|
+
//
|
|
80
|
+
// Agent-DM wakes also arrive as chat.mention, but carry payload.dmKind. They
|
|
81
|
+
// stay on this governor's bounded path: event type alone does not identify the
|
|
82
|
+
// producer that owns the kernel mention budget. dm.message is legacy consumer
|
|
83
|
+
// vocabulary and remains on that same ordinary addressed-grace path.
|
|
84
|
+
export const MENTION_EVENT_TYPES = new Set(['chat.mention', 'thread.mention']);
|
|
85
|
+
|
|
65
86
|
// ── cascade governor ────────────────────────────────────────────────────────
|
|
66
87
|
|
|
88
|
+
export const CASCADE_DEFAULTS = Object.freeze({
|
|
89
|
+
cap: 3,
|
|
90
|
+
addressedGrace: 2,
|
|
91
|
+
resetMs: 10 * 60 * 1000,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Env names, exported so the CLI help text and the tests quote the same
|
|
95
|
+
// strings rather than two copies that can drift apart.
|
|
96
|
+
export const CASCADE_ENV_VARS = Object.freeze({
|
|
97
|
+
cap: 'COMMONLY_CASCADE_CAP',
|
|
98
|
+
addressedGrace: 'COMMONLY_CASCADE_ADDRESSED_GRACE',
|
|
99
|
+
resetMs: 'COMMONLY_CASCADE_RESET_MS',
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Flag spelling per key, for warning messages. A derived string
|
|
103
|
+
// (key.replace(...)) would silently produce a flag that does not exist the
|
|
104
|
+
// first time a key is renamed; this fails at the same moment the flag does.
|
|
105
|
+
const CASCADE_FLAGS = {
|
|
106
|
+
cap: '--cascade-cap',
|
|
107
|
+
addressedGrace: '--cascade-grace',
|
|
108
|
+
resetMs: '--cascade-reset',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const CASCADE_BOUNDS = {
|
|
112
|
+
// 0 is meaningful, not a mistake: it refuses every agent-triggered turn in
|
|
113
|
+
// the pod, which is the "this room is on fire, mute the cascades" setting.
|
|
114
|
+
cap: { min: 0, max: 1000, integer: true },
|
|
115
|
+
// 0 restores the pre-#973 behaviour exactly. That is the point of the knob —
|
|
116
|
+
// the grace can be taken back on one seat, in one restart, without a revert
|
|
117
|
+
// and without a source edit.
|
|
118
|
+
addressedGrace: { min: 0, max: 1000, integer: true },
|
|
119
|
+
resetMs: { min: 1000, max: 24 * 60 * 60 * 1000, integer: true },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Resolve the three governor constants from (in precedence order) an explicit
|
|
124
|
+
* override, an environment variable, then the shipped default.
|
|
125
|
+
*
|
|
126
|
+
* These were literals in `run()`'s signature, reachable only by editing source
|
|
127
|
+
* — on a fleet whose CLI is a symlink into a live worktree, so "retune the
|
|
128
|
+
* cap" meant an edit plus a restart of every seat, with nothing recording
|
|
129
|
+
* which value ran. The sibling dampener one service over
|
|
130
|
+
* (AGENT_ASK_RATE_LIMIT_PER_HOUR) has always been env-tunable; this is
|
|
131
|
+
* copying that, not inventing it.
|
|
132
|
+
*
|
|
133
|
+
* An unparseable or out-of-range value warns and falls back rather than
|
|
134
|
+
* throwing. A seat that crash-loops on a typo'd env var is a worse outcome
|
|
135
|
+
* than one running the default — but a silent fallback would hide the typo,
|
|
136
|
+
* so it is loud.
|
|
137
|
+
*
|
|
138
|
+
* Overrides are validated on the SAME path as env values, deliberately. An
|
|
139
|
+
* earlier draft trusted them and passed them straight through, which made
|
|
140
|
+
* `--cascade-cap abc` resolve to NaN — and `streak < NaN` is false for every
|
|
141
|
+
* streak, so a typo at the command line would have silently refused every
|
|
142
|
+
* agent-triggered turn the seat ever saw. The louder source is not the safer
|
|
143
|
+
* one; there is one validator because there is one way to be wrong.
|
|
144
|
+
*/
|
|
145
|
+
export const resolveCascadeSettings = ({
|
|
146
|
+
env = process.env,
|
|
147
|
+
overrides = {},
|
|
148
|
+
warn = (msg) => console.warn(msg),
|
|
149
|
+
} = {}) => {
|
|
150
|
+
const resolved = {};
|
|
151
|
+
for (const key of Object.keys(CASCADE_DEFAULTS)) {
|
|
152
|
+
const bounds = CASCADE_BOUNDS[key];
|
|
153
|
+
const fallback = CASCADE_DEFAULTS[key];
|
|
154
|
+
const override = overrides[key];
|
|
155
|
+
const hasOverride = override !== undefined && override !== null;
|
|
156
|
+
const raw = hasOverride ? override : env?.[CASCADE_ENV_VARS[key]];
|
|
157
|
+
// Names the source in the warning, so "which one did I get wrong" is
|
|
158
|
+
// answered by the message rather than by bisecting the launch command.
|
|
159
|
+
const label = hasOverride ? CASCADE_FLAGS[key] : CASCADE_ENV_VARS[key];
|
|
160
|
+
|
|
161
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
162
|
+
resolved[key] = fallback;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const parsed = Number(raw);
|
|
166
|
+
if (!Number.isFinite(parsed) || (bounds.integer && !Number.isInteger(parsed))) {
|
|
167
|
+
warn(`${label}='${raw}' is not an integer — using ${fallback}`);
|
|
168
|
+
resolved[key] = fallback;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (parsed < bounds.min || parsed > bounds.max) {
|
|
172
|
+
warn(`${label}=${parsed} is outside [${bounds.min}, ${bounds.max}] — using ${fallback}`);
|
|
173
|
+
resolved[key] = fallback;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
resolved[key] = parsed;
|
|
177
|
+
}
|
|
178
|
+
return resolved;
|
|
179
|
+
};
|
|
180
|
+
|
|
67
181
|
/**
|
|
68
182
|
* Per-pod damping of agent→agent retrigger chains.
|
|
69
183
|
*
|
|
@@ -71,23 +185,42 @@ export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention',
|
|
|
71
185
|
* agent's replies) until the operator killed the wrapper at round 3. The
|
|
72
186
|
* governor counts CONSECUTIVE agent-triggered turns per pod; at `cap` it
|
|
73
187
|
* refuses further agent-triggered turns until a human-triggered turn resets
|
|
74
|
-
* the streak or `resetMs` passes with no agent-triggered turn
|
|
75
|
-
* pod recovers on its own — a legitimate
|
|
76
|
-
* inherit a stale cap).
|
|
188
|
+
* the streak, or `resetMs` passes with no ADMITTED agent-triggered turn from
|
|
189
|
+
* this seat in this pod (so a damped seat recovers on its own — a legitimate
|
|
190
|
+
* a2a handoff an hour later must not inherit a stale cap).
|
|
191
|
+
*
|
|
192
|
+
* NOTE on `resetMs`: it is a silence window, but the silence is far narrower
|
|
193
|
+
* than "the pod is quiet", and it is measured on two axes people get wrong in
|
|
194
|
+
* opposite directions:
|
|
195
|
+
*
|
|
196
|
+
* WHOSE turns — `pods` is a Map in this closure, inside ONE wrapper
|
|
197
|
+
* process. It never observes another seat's turns at all.
|
|
198
|
+
* Three chatty peers cannot keep this streak alive.
|
|
199
|
+
* WHICH turns — only turns that reached `record()`. A refusal returns from
|
|
200
|
+
* agent.js before it (`:859` vs `:1087`), so a capped seat
|
|
201
|
+
* stops updating `lastAgentTurnAt` entirely and its clock
|
|
202
|
+
* runs free from the last turn it was ALLOWED.
|
|
203
|
+
*
|
|
204
|
+
* Net shape is a token bucket, not a static ceiling: a burst of `cap`, then
|
|
205
|
+
* `cap` admits per `resetMs`, per pod. Sustained agent traffic with zero human
|
|
206
|
+
* turns yields 6/9/12/15/18/18/18/18 admits per hour as arrivals go from one
|
|
207
|
+
* per 600s to one per second — inert when quiet, pinned at cap-per-window
|
|
208
|
+
* however hard the burst gets. Per POD: a seat in N pods holds N buckets, so
|
|
209
|
+
* its seat-level ceiling is N times that.
|
|
77
210
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
211
|
+
* An earlier version of this note said the window requires zero agent turns
|
|
212
|
+
* "in the pod", and concluded that in a busy room the only live release is a
|
|
213
|
+
* human turn. Both halves are wrong, and wrongly reassuring: a capped seat
|
|
214
|
+
* self-releases every window no matter how loud the room is.
|
|
82
215
|
*
|
|
83
216
|
* Split into admit/record so a spawn that fails (and will be redelivered)
|
|
84
217
|
* never double-counts: admit() only reads, record() runs after a turn
|
|
85
218
|
* actually completed. Human-triggered turns are always admitted.
|
|
86
219
|
*/
|
|
87
220
|
export const createCascadeGovernor = ({
|
|
88
|
-
cap =
|
|
89
|
-
addressedGrace =
|
|
90
|
-
resetMs =
|
|
221
|
+
cap = CASCADE_DEFAULTS.cap,
|
|
222
|
+
addressedGrace = CASCADE_DEFAULTS.addressedGrace,
|
|
223
|
+
resetMs = CASCADE_DEFAULTS.resetMs,
|
|
91
224
|
now = Date.now,
|
|
92
225
|
} = {}) => {
|
|
93
226
|
const pods = new Map(); // podId -> { streak, lastAgentTurnAt }
|
|
@@ -101,9 +234,24 @@ export const createCascadeGovernor = ({
|
|
|
101
234
|
};
|
|
102
235
|
|
|
103
236
|
return {
|
|
104
|
-
admit(podId, trigger, eventType) {
|
|
237
|
+
admit(podId, trigger, eventType, payload = {}) {
|
|
105
238
|
if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
|
|
106
239
|
const s = stateFor(podId);
|
|
240
|
+
const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
|
|
241
|
+
if (MENTION_EVENT_TYPES.has(eventType) && !payload?.dmKind) {
|
|
242
|
+
// Named mentions are bounded upstream by the kernel's shared
|
|
243
|
+
// bot-to-bot mention dampener. A DM emits chat.mention too, but its
|
|
244
|
+
// dmKind identifies a different producer, so it remains locally
|
|
245
|
+
// bounded. Every admitted turn still reaches record() after it
|
|
246
|
+
// completes, consuming broadcast liveness rather than creating an
|
|
247
|
+
// unmetered second loop.
|
|
248
|
+
return {
|
|
249
|
+
allowed: true,
|
|
250
|
+
streak: s.streak,
|
|
251
|
+
addressed,
|
|
252
|
+
graceApplied: false,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
107
255
|
// Being NAMED outranks a mechanical brake — the same judgement the claim
|
|
108
256
|
// path already makes forty lines down in agent.js. Without this, a peer
|
|
109
257
|
// can @mention a capped seat and get silence, with no signal to either
|
|
@@ -111,13 +259,22 @@ export const createCascadeGovernor = ({
|
|
|
111
259
|
// 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
|
|
112
260
|
// and answered none of them.
|
|
113
261
|
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// cap + addressedGrace instead of never.
|
|
118
|
-
const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
|
|
262
|
+
// Legacy direct-address events retain a GRACE, not an exemption. They
|
|
263
|
+
// still count toward the streak, so they terminate at cap +
|
|
264
|
+
// addressedGrace instead of never.
|
|
119
265
|
const limit = addressed ? cap + addressedGrace : cap;
|
|
120
|
-
|
|
266
|
+
// `addressed` describes the EVENT; `graceApplied` describes what this
|
|
267
|
+
// governor actually did with it. They diverge whenever addressedGrace is
|
|
268
|
+
// 0 — the pre-#973 setting, and the first thing an operator dials back —
|
|
269
|
+
// where an addressed event is refused at the plain cap having been given
|
|
270
|
+
// nothing extra. Reporting only `addressed` made the refusal log claim a
|
|
271
|
+
// grace was "also spent" on a seat whose own boot line says grace=0.
|
|
272
|
+
return {
|
|
273
|
+
allowed: s.streak < limit,
|
|
274
|
+
streak: s.streak,
|
|
275
|
+
addressed,
|
|
276
|
+
graceApplied: addressed && addressedGrace > 0,
|
|
277
|
+
};
|
|
121
278
|
},
|
|
122
279
|
record(podId, trigger) {
|
|
123
280
|
if (trigger === 'human') {
|
|
@@ -126,7 +283,8 @@ export const createCascadeGovernor = ({
|
|
|
126
283
|
const s = stateFor(podId);
|
|
127
284
|
pods.set(podId, { streak: s.streak + 1, lastAgentTurnAt: now() });
|
|
128
285
|
}
|
|
129
|
-
// 'unknown' is neutral
|
|
286
|
+
// 'unknown' is neutral by FALLBACK; 'kernel' is neutral by DESIGN —
|
|
287
|
+
// both take this path, but only one of them is an accident.
|
|
130
288
|
},
|
|
131
289
|
};
|
|
132
290
|
};
|