@ouro.bot/cli 0.1.0-alpha.497 → 0.1.0-alpha.498

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/changelog.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.498",
6
+ "changes": [
7
+ "Heartbeat / habit recursion detection in the inner-dialog worker. The existing instinct cap (`MAX_CONSECUTIVE_INSTINCT_TURNS=3`) protects against the *internal* pending-dir self-loop (a turn writes back to its own pending dir, drains it, repeats). It does not protect against the *external* IPC self-loop where heartbeat-shaped messages get re-issued faster than their cadence — e.g. a hook misconfigured to repost on every heartbeat, a daemon retry storm, or two timers drifting into the same window.",
8
+ "Two new warn-level nerve events: `senses.habit_recursion_suspected` fires when two of the same habit (e.g. `heartbeat`) arrive within `HABIT_RECURSION_MIN_INTERVAL_MS` (5s) — no realistic cadence runs that fast. `senses.habit_recursion_burst` fires when `HABIT_RECURSION_BURST_THRESHOLD` (5) or more habit messages of any kind land within `HABIT_RECURSION_BURST_WINDOW_MS` (60s) — catches slower runaways that stay just under the min-interval threshold.",
9
+ "Detection is observation-only by design: it emits the warn signal so an operator (or a follow-up auto-recovery layer) can act on it. The message is not dropped — the signal is the value. Per-habit-name tracking, so two distinct habits firing close together don't trip the min-interval warning. `nowSource` is injectable via the `createInnerDialogWorker` factory for deterministic tests. 5 new tests cover both detectors plus the trim-window and per-habit isolation cases."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.497",
6
14
  "changes": [
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MAX_CONSECUTIVE_INSTINCT_TURNS = void 0;
36
+ exports.HABIT_RECURSION_BURST_THRESHOLD = exports.HABIT_RECURSION_BURST_WINDOW_MS = exports.HABIT_RECURSION_MIN_INTERVAL_MS = exports.MAX_CONSECUTIVE_INSTINCT_TURNS = void 0;
37
37
  exports.createInnerDialogWorker = createInnerDialogWorker;
38
38
  exports.startInnerDialogWorker = startInnerDialogWorker;
39
39
  const path = __importStar(require("path"));
@@ -56,9 +56,69 @@ const habit_runtime_state_1 = require("../heart/habits/habit-runtime-state");
56
56
  * batch of delegated returns) get through; a true self-loop caps fast.
57
57
  */
58
58
  exports.MAX_CONSECUTIVE_INSTINCT_TURNS = 3;
59
- function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)()))) {
59
+ /**
60
+ * Habit recursion detector thresholds. The instinct cap above protects
61
+ * against pending-dir self-loops; this protects against the *external*
62
+ * IPC self-loop where heartbeat-shaped messages get re-issued faster
63
+ * than their cadence — e.g. a hook misconfigured to repost on every
64
+ * heartbeat, a daemon retry storm, or a stuck timer firing back-to-back.
65
+ *
66
+ * MIN_INTERVAL_MS — two of the same habit within this window is suspect
67
+ * regardless of cadence (no realistic habit fires every few seconds).
68
+ * BURST_THRESHOLD over BURST_WINDOW_MS catches slower runaways that stay
69
+ * just under MIN_INTERVAL_MS.
70
+ *
71
+ * Detection is observation-only: it emits warn-level nerves events, it
72
+ * does not drop the message. An operator (or follow-up auto-recovery)
73
+ * decides what to do with the signal.
74
+ */
75
+ exports.HABIT_RECURSION_MIN_INTERVAL_MS = 5_000;
76
+ exports.HABIT_RECURSION_BURST_WINDOW_MS = 60_000;
77
+ exports.HABIT_RECURSION_BURST_THRESHOLD = 5;
78
+ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)())), nowSource = () => Date.now()) {
60
79
  let running = false;
61
80
  const queue = [];
81
+ const lastFireByHabit = new Map();
82
+ const recentHabitFires = [];
83
+ function recordHabitFireForRecursion(habitName) {
84
+ const now = nowSource();
85
+ const previous = lastFireByHabit.get(habitName);
86
+ if (previous !== undefined) {
87
+ const intervalMs = now - previous;
88
+ if (intervalMs < exports.HABIT_RECURSION_MIN_INTERVAL_MS) {
89
+ (0, runtime_1.emitNervesEvent)({
90
+ level: "warn",
91
+ component: "senses",
92
+ event: "senses.habit_recursion_suspected",
93
+ message: "habit fired suspiciously fast after the previous fire — possible self-recursion or duplicate dispatch",
94
+ meta: {
95
+ habitName,
96
+ intervalMs,
97
+ thresholdMs: exports.HABIT_RECURSION_MIN_INTERVAL_MS,
98
+ },
99
+ });
100
+ }
101
+ }
102
+ lastFireByHabit.set(habitName, now);
103
+ recentHabitFires.push(now);
104
+ while (recentHabitFires.length > 0 && now - recentHabitFires[0] > exports.HABIT_RECURSION_BURST_WINDOW_MS) {
105
+ recentHabitFires.shift();
106
+ }
107
+ if (recentHabitFires.length >= exports.HABIT_RECURSION_BURST_THRESHOLD) {
108
+ (0, runtime_1.emitNervesEvent)({
109
+ level: "warn",
110
+ component: "senses",
111
+ event: "senses.habit_recursion_burst",
112
+ message: "habit messages arriving in a burst — possible runaway loop",
113
+ meta: {
114
+ count: recentHabitFires.length,
115
+ windowMs: exports.HABIT_RECURSION_BURST_WINDOW_MS,
116
+ thresholdCount: exports.HABIT_RECURSION_BURST_THRESHOLD,
117
+ lastHabitName: habitName,
118
+ },
119
+ });
120
+ }
121
+ }
62
122
  async function run(reason, taskId, habitName) {
63
123
  if (running) {
64
124
  queue.push({ reason, taskId, habitName });
@@ -145,11 +205,15 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
145
205
  return;
146
206
  const maybeMessage = message;
147
207
  if (maybeMessage.type === "habit") {
208
+ /* v8 ignore next -- defensive fallback: live habit dispatch always sets habitName @preserve */
209
+ const habitName = maybeMessage.habitName ?? "(unnamed)";
210
+ recordHabitFireForRecursion(habitName);
148
211
  await run("habit", undefined, maybeMessage.habitName);
149
212
  return;
150
213
  }
151
214
  if (maybeMessage.type === "heartbeat") {
152
215
  // Backward compatibility: heartbeat -> habit/heartbeat
216
+ recordHabitFireForRecursion("heartbeat");
153
217
  await run("habit", undefined, "heartbeat");
154
218
  return;
155
219
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.497",
3
+ "version": "0.1.0-alpha.498",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",