@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
- if (hookEvent !== 'sessionStart' && cbState.lastPollTs) {
89
- const lastMs = Date.parse(cbState.lastPollTs);
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 < resolved.minPollIntervalSeconds)
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
- consecutiveFailures: (cbState.consecutiveFailures ?? 0) + 1,
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: patch.commandBus.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
- return { enabled, hooks, minPollIntervalSeconds, reactionMode };
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
  };
@@ -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)
@@ -96,6 +96,7 @@ exports.DEFAULT_COMPANION_STATE = Object.freeze({
96
96
  commandBus: {
97
97
  cursor: null,
98
98
  lastPollTs: null,
99
+ lastAttemptTs: null,
99
100
  consecutiveFailures: 0,
100
101
  recentEventIds: [],
101
102
  },
@@ -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.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",