@dezycro-ai/agent-plugin 2.1.1 → 2.1.2
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.
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* has elapsed since the last successful poll.
|
|
9
9
|
* * On 2xx: advance cursor + lastPollTs, dedup by `eventId` (recent ring, cap 200),
|
|
10
10
|
* return new events for surfacing.
|
|
11
|
-
* * On non-2xx / network failure: cursor stays put, consecutiveFailures
|
|
11
|
+
* * On non-2xx / network failure: cursor stays put, consecutiveFailures++, and
|
|
12
|
+
* `lastAttemptTs` advances so the floor throttles repeated failures. Once
|
|
13
|
+
* failures cross `circuitBreakerThreshold` the floor grows exponentially
|
|
14
|
+
* (capped at `maxBackoffSeconds`), so an unreachable endpoint is not polled on
|
|
15
|
+
* every hook fire. A single success resets the counter and the interval.
|
|
12
16
|
*
|
|
13
17
|
* Auth: a bearer token from `.dezycro/companion-token.local.json` (`{"token": "dzy_…"}`)
|
|
14
18
|
* is sent on every request. Separate from the MCP server token (the user maintains both).
|
|
@@ -27,7 +31,15 @@ interface ResolvedConfig {
|
|
|
27
31
|
hooks: ReadonlySet<CommandBusHook>;
|
|
28
32
|
minPollIntervalSeconds: number;
|
|
29
33
|
reactionMode: CommandBusReactionMode;
|
|
34
|
+
circuitBreakerThreshold: number;
|
|
35
|
+
maxBackoffSeconds: number;
|
|
30
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Effective poll interval given the number of consecutive failures so far.
|
|
39
|
+
* Returns the base interval until `threshold` failures, then doubles per extra
|
|
40
|
+
* failure, capped at `maxSeconds`. Never throws.
|
|
41
|
+
*/
|
|
42
|
+
export declare function backoffSeconds(baseSeconds: number, consecutiveFailures: number, threshold: number, maxSeconds: number): number;
|
|
31
43
|
/**
|
|
32
44
|
* Entry point called from each hook handler. Returns the events that should be surfaced
|
|
33
45
|
* to the agent on this hook fire, plus a state patch the caller must apply.
|
|
@@ -49,5 +61,6 @@ export declare const _internals: {
|
|
|
49
61
|
renderEvent: typeof renderEvent;
|
|
50
62
|
reactionGuidance: typeof reactionGuidance;
|
|
51
63
|
trimRecent: typeof trimRecent;
|
|
64
|
+
backoffSeconds: typeof backoffSeconds;
|
|
52
65
|
};
|
|
53
66
|
export {};
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
* has elapsed since the last successful poll.
|
|
9
9
|
* * On 2xx: advance cursor + lastPollTs, dedup by `eventId` (recent ring, cap 200),
|
|
10
10
|
* return new events for surfacing.
|
|
11
|
-
* * On non-2xx / network failure: cursor stays put, consecutiveFailures
|
|
11
|
+
* * On non-2xx / network failure: cursor stays put, consecutiveFailures++, and
|
|
12
|
+
* `lastAttemptTs` advances so the floor throttles repeated failures. Once
|
|
13
|
+
* failures cross `circuitBreakerThreshold` the floor grows exponentially
|
|
14
|
+
* (capped at `maxBackoffSeconds`), so an unreachable endpoint is not polled on
|
|
15
|
+
* every hook fire. A single success resets the counter and the interval.
|
|
12
16
|
*
|
|
13
17
|
* Auth: a bearer token from `.dezycro/companion-token.local.json` (`{"token": "dzy_…"}`)
|
|
14
18
|
* is sent on every request. Separate from the MCP server token (the user maintains both).
|
|
@@ -52,6 +56,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
52
56
|
})();
|
|
53
57
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
58
|
exports._internals = void 0;
|
|
59
|
+
exports.backoffSeconds = backoffSeconds;
|
|
55
60
|
exports.pollIfDue = pollIfDue;
|
|
56
61
|
exports.renderForAgent = renderForAgent;
|
|
57
62
|
const fs = __importStar(require("fs"));
|
|
@@ -68,6 +73,24 @@ const DEFAULT_MIN_POLL_INTERVAL_SECONDS = 60;
|
|
|
68
73
|
const DEFAULT_REACTION_MODE = 'HALT_AND_REPLAN';
|
|
69
74
|
const RECENT_EVENT_IDS_CAP = 200;
|
|
70
75
|
const POLL_LIMIT = 100;
|
|
76
|
+
// After this many consecutive failures the poll interval starts doubling per
|
|
77
|
+
// extra failure (capped at maxBackoffSeconds) so an unreachable inbox endpoint
|
|
78
|
+
// — e.g. a default `local` env that isn't running — stops being polled on every
|
|
79
|
+
// hook fire. Self-heals: a single success resets consecutiveFailures to 0.
|
|
80
|
+
const DEFAULT_CIRCUIT_BREAKER_THRESHOLD = 3;
|
|
81
|
+
const DEFAULT_MAX_BACKOFF_SECONDS = 1800;
|
|
82
|
+
/**
|
|
83
|
+
* Effective poll interval given the number of consecutive failures so far.
|
|
84
|
+
* Returns the base interval until `threshold` failures, then doubles per extra
|
|
85
|
+
* failure, capped at `maxSeconds`. Never throws.
|
|
86
|
+
*/
|
|
87
|
+
function backoffSeconds(baseSeconds, consecutiveFailures, threshold, maxSeconds) {
|
|
88
|
+
if (consecutiveFailures < threshold)
|
|
89
|
+
return baseSeconds;
|
|
90
|
+
const extra = consecutiveFailures - threshold + 1;
|
|
91
|
+
const scaled = baseSeconds * Math.pow(2, extra);
|
|
92
|
+
return Math.min(scaled, maxSeconds);
|
|
93
|
+
}
|
|
71
94
|
/**
|
|
72
95
|
* Entry point called from each hook handler. Returns the events that should be surfaced
|
|
73
96
|
* to the agent on this hook fire, plus a state patch the caller must apply.
|
|
@@ -85,11 +108,17 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
|
|
|
85
108
|
// SessionStart always polls regardless of the configured hook set — bootstrap-on-start
|
|
86
109
|
// is non-negotiable for cursor recovery after compaction.
|
|
87
110
|
const cbState = state.commandBus;
|
|
88
|
-
|
|
89
|
-
|
|
111
|
+
// Throttle from the last ATTEMPT (success or failure), not just the last
|
|
112
|
+
// success — otherwise a never-succeeding endpoint (lastPollTs stays null) is
|
|
113
|
+
// polled on every hook fire. The floor grows via exponential backoff once
|
|
114
|
+
// consecutive failures cross the circuit-breaker threshold.
|
|
115
|
+
const lastAttempt = cbState.lastAttemptTs ?? cbState.lastPollTs;
|
|
116
|
+
if (hookEvent !== 'sessionStart' && lastAttempt) {
|
|
117
|
+
const lastMs = Date.parse(lastAttempt);
|
|
90
118
|
if (!Number.isNaN(lastMs)) {
|
|
119
|
+
const floorSeconds = backoffSeconds(resolved.minPollIntervalSeconds, cbState.consecutiveFailures ?? 0, resolved.circuitBreakerThreshold, resolved.maxBackoffSeconds);
|
|
91
120
|
const ageSeconds = (Date.now() - lastMs) / 1000;
|
|
92
|
-
if (ageSeconds <
|
|
121
|
+
if (ageSeconds < floorSeconds)
|
|
93
122
|
return empty;
|
|
94
123
|
}
|
|
95
124
|
}
|
|
@@ -105,6 +134,7 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
|
|
|
105
134
|
return empty;
|
|
106
135
|
}
|
|
107
136
|
const since = hookEvent === 'sessionStart' ? null : cbState.cursor;
|
|
137
|
+
const attemptTs = new Date().toISOString();
|
|
108
138
|
try {
|
|
109
139
|
const fetched = await fetchInbox(apiUrl, feature, token, since);
|
|
110
140
|
const seen = new Set(cbState.recentEventIds ?? []);
|
|
@@ -114,6 +144,7 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
|
|
|
114
144
|
commandBus: {
|
|
115
145
|
cursor: fetched.nextCursor || cbState.cursor,
|
|
116
146
|
lastPollTs: fetched.serverTs,
|
|
147
|
+
lastAttemptTs: attemptTs,
|
|
117
148
|
consecutiveFailures: 0,
|
|
118
149
|
recentEventIds: newRing,
|
|
119
150
|
},
|
|
@@ -128,16 +159,21 @@ async function pollIfDue(repoRoot, hookEvent, config, state) {
|
|
|
128
159
|
return { events: fresh, statePatch: patch, reactionMode };
|
|
129
160
|
}
|
|
130
161
|
catch (e) {
|
|
162
|
+
const consecutiveFailures = (cbState.consecutiveFailures ?? 0) + 1;
|
|
131
163
|
const patch = {
|
|
132
164
|
commandBus: {
|
|
133
165
|
...cbState,
|
|
134
|
-
|
|
166
|
+
lastAttemptTs: attemptTs,
|
|
167
|
+
consecutiveFailures,
|
|
135
168
|
},
|
|
136
169
|
};
|
|
170
|
+
const nextFloor = backoffSeconds(resolved.minPollIntervalSeconds, consecutiveFailures, resolved.circuitBreakerThreshold, resolved.maxBackoffSeconds);
|
|
137
171
|
logging.info(repoRoot, 'command_bus_poll_failed', {
|
|
138
172
|
hookEvent,
|
|
139
173
|
error: e instanceof Error ? e.message : String(e),
|
|
140
|
-
consecutiveFailures
|
|
174
|
+
consecutiveFailures,
|
|
175
|
+
nextPollAfterSeconds: nextFloor,
|
|
176
|
+
backoff: consecutiveFailures >= resolved.circuitBreakerThreshold,
|
|
141
177
|
});
|
|
142
178
|
return { events: [], statePatch: patch, reactionMode };
|
|
143
179
|
}
|
|
@@ -190,7 +226,9 @@ function resolveConfig(c) {
|
|
|
190
226
|
hooks.add('sessionStart'); // bootstrap is non-removable
|
|
191
227
|
const minPollIntervalSeconds = Math.max(0, c?.minPollIntervalSeconds ?? DEFAULT_MIN_POLL_INTERVAL_SECONDS);
|
|
192
228
|
const reactionMode = c?.reactionMode ?? DEFAULT_REACTION_MODE;
|
|
193
|
-
|
|
229
|
+
const circuitBreakerThreshold = Math.max(1, c?.circuitBreakerThreshold ?? DEFAULT_CIRCUIT_BREAKER_THRESHOLD);
|
|
230
|
+
const maxBackoffSeconds = Math.max(minPollIntervalSeconds, c?.maxBackoffSeconds ?? DEFAULT_MAX_BACKOFF_SECONDS);
|
|
231
|
+
return { enabled, hooks, minPollIntervalSeconds, reactionMode, circuitBreakerThreshold, maxBackoffSeconds };
|
|
194
232
|
}
|
|
195
233
|
function readActiveFeature(repoRoot) {
|
|
196
234
|
try {
|
|
@@ -315,4 +353,5 @@ exports._internals = {
|
|
|
315
353
|
renderEvent,
|
|
316
354
|
reactionGuidance,
|
|
317
355
|
trimRecent,
|
|
356
|
+
backoffSeconds,
|
|
318
357
|
};
|
package/dist/hooks/lib/router.js
CHANGED
|
@@ -50,6 +50,7 @@ const config = __importStar(require("./config"));
|
|
|
50
50
|
const state = __importStar(require("./state"));
|
|
51
51
|
const logging = __importStar(require("./logging"));
|
|
52
52
|
const verify = __importStar(require("./verify"));
|
|
53
|
+
const publishSpec = __importStar(require("./publish-spec"));
|
|
53
54
|
const controllerMatch = __importStar(require("./controller-match"));
|
|
54
55
|
const authoring = __importStar(require("./authoring"));
|
|
55
56
|
const qualityGate = __importStar(require("./quality-gate"));
|
|
@@ -509,10 +510,57 @@ async function handleUserPromptSubmit(payload) {
|
|
|
509
510
|
}
|
|
510
511
|
}
|
|
511
512
|
}
|
|
513
|
+
// Pillar 2 — persistent publish-spec/verify reminder. The PostToolUse pulse
|
|
514
|
+
// nudge only fires on the exact edit that touches a controller path (and only
|
|
515
|
+
// once pulseCount crosses the threshold), so endpoint changes routinely go
|
|
516
|
+
// un-surfaced. Re-surface on every UserPromptSubmit while needsSpecPublish is
|
|
517
|
+
// set, so the agent is reminded each turn until it publishes the spec. Gated
|
|
518
|
+
// by companion.autoVerify.autoPublishSpecOnControllerChange (default true).
|
|
519
|
+
{
|
|
520
|
+
const specNudge = buildPublishSpecNudge(repoRoot);
|
|
521
|
+
if (specNudge) {
|
|
522
|
+
logging.info(repoRoot, 'publish_spec_nudge', { paths: specNudge.count });
|
|
523
|
+
const out = {
|
|
524
|
+
hookSpecificOutput: {
|
|
525
|
+
hookEventName: 'UserPromptSubmit',
|
|
526
|
+
additionalContext: specNudge.text,
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
process.stdout.write(JSON.stringify(out));
|
|
530
|
+
return 0;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
512
533
|
maybeSurfaceCoverage(repoRoot, current);
|
|
513
534
|
process.stdout.write('{}');
|
|
514
535
|
return 0;
|
|
515
536
|
}
|
|
537
|
+
/**
|
|
538
|
+
* Build the persistent publish-spec/verify reminder, or null when nothing is
|
|
539
|
+
* pending or the feature is disabled. Surfaced on every UserPromptSubmit while
|
|
540
|
+
* `needsSpecPublish` is set; clears automatically once `/dezycro:publish-spec`
|
|
541
|
+
* resets the flags.
|
|
542
|
+
*/
|
|
543
|
+
function buildPublishSpecNudge(repoRoot) {
|
|
544
|
+
const cfg = safeLoadConfig(repoRoot);
|
|
545
|
+
const av = (cfg && cfg.companion && cfg.companion.autoVerify) || {};
|
|
546
|
+
if (av.autoPublishSpecOnControllerChange === false)
|
|
547
|
+
return null;
|
|
548
|
+
const check = publishSpec.needsPublishBeforeVerify(repoRoot);
|
|
549
|
+
if (!check.needed || check.controllerPaths.length === 0)
|
|
550
|
+
return null;
|
|
551
|
+
const MAX_LISTED = 8;
|
|
552
|
+
const paths = check.controllerPaths;
|
|
553
|
+
const shown = paths.slice(0, MAX_LISTED).map((p) => ' - ' + p);
|
|
554
|
+
if (paths.length > MAX_LISTED)
|
|
555
|
+
shown.push(' - …and ' + (paths.length - MAX_LISTED) + ' more');
|
|
556
|
+
const text = '[Dezycro Companion] ' + paths.length + ' controller/API file(s) changed since the last published spec:\n' +
|
|
557
|
+
shown.join('\n') + '\n\n' +
|
|
558
|
+
'New or changed endpoints are NOT yet reflected in the published OpenAPI spec, so verification ' +
|
|
559
|
+
'cannot see them. Before continuing, run `/dezycro:publish-spec` to upload the updated spec (this ' +
|
|
560
|
+
'regenerates the JEP), then `/dezycro:verify` to check the feature against it. This reminder repeats ' +
|
|
561
|
+
'each turn until the spec is published and clears automatically after `/dezycro:publish-spec`.';
|
|
562
|
+
return { text, count: paths.length };
|
|
563
|
+
}
|
|
516
564
|
function maybeSurfaceCoverage(repoRoot, current) {
|
|
517
565
|
const cfg = safeLoadConfig(repoRoot);
|
|
518
566
|
if (cfg?.companion?.coverageNudges?.enabled === false)
|
package/dist/hooks/lib/state.js
CHANGED
package/dist/hooks/types.d.ts
CHANGED
|
@@ -206,10 +206,25 @@ export interface CommandBusConfig {
|
|
|
206
206
|
pollHooks?: CommandBusHook[];
|
|
207
207
|
minPollIntervalSeconds?: number;
|
|
208
208
|
reactionMode?: CommandBusReactionMode;
|
|
209
|
+
/**
|
|
210
|
+
* After this many consecutive poll failures the effective poll interval grows
|
|
211
|
+
* exponentially (backoff) so an unreachable inbox endpoint stops being polled
|
|
212
|
+
* on every hook fire. Defaults to 3.
|
|
213
|
+
*/
|
|
214
|
+
circuitBreakerThreshold?: number;
|
|
215
|
+
/** Upper bound (seconds) on the backed-off poll interval. Defaults to 1800 (30m). */
|
|
216
|
+
maxBackoffSeconds?: number;
|
|
209
217
|
}
|
|
210
218
|
export interface CommandBusState {
|
|
211
219
|
cursor: string | null;
|
|
220
|
+
/** Timestamp of the last SUCCESSFUL poll (server clock). Used as the dedup/cursor anchor. */
|
|
212
221
|
lastPollTs: string | null;
|
|
222
|
+
/**
|
|
223
|
+
* Timestamp of the last poll ATTEMPT (success or failure, client clock). The
|
|
224
|
+
* min-interval / backoff floor is measured from here so repeated failures are
|
|
225
|
+
* throttled even before a first success exists.
|
|
226
|
+
*/
|
|
227
|
+
lastAttemptTs?: string | null;
|
|
213
228
|
consecutiveFailures: number;
|
|
214
229
|
recentEventIds: string[];
|
|
215
230
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dezycro-ai/agent-plugin",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "Official Dezycro plugin for AI coding agents (Claude Code today, more to come) — reverse-engineer your codebase into features, PRDs, TRDs, and live verification.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"dezycro-setup": "./setup.js",
|