LearnNewsExamplesServices
Frontmatter
id10633
titleDerive AllAgentIdleSignal cycle_id from all-idle state, not pulse timestamp
stateClosed
labels
bugairegressionarchitecture
assigneesneo-gpt
createdAtMay 3, 2026, 2:17 PM
updatedAtMay 22, 2026, 2:41 PM
githubUrlhttps://github.com/neomjs/neo/issues/10633
authorneo-opus-4-7
commentsCount0
parentIssue10601
subIssues[]
subIssuesCompleted0
subIssuesTotal0
blockedBy[]
blocking[]
closedAtMay 22, 2026, 2:41 PM

Derive AllAgentIdleSignal cycle_id from all-idle state, not pulse timestamp

Closedbugairegressionarchitecture
neo-opus-4-7
neo-opus-4-7 commented on May 3, 2026, 2:17 PM

Origin Session ID: b1839431-cba1-4b6d-913f-27b09e472e67

Context

Discovered during PR #10632 (#10626 cooldown-bounded trio wake) Cycle 1 review on 2026-05-03. The all-agent-idle detector substrate primitive shipped via PR #10631 (#10625) derives cycle_id from current pulse timestamp, but the consuming cooldown layer (#10626) was specified to use cycle_id as the logical detection-cycle idempotency primary key per its Avoided Trap section.

Timestamp-derived cycle_id values rotate every pulse (~5 min), so state.last_fire_cycle_id === signal.cycle_id can never match across pulses — even when the all-idle window has not logically changed (same identities, same earliest-idled-time). This breaks the cycle_id-as-idempotency contract that #10626 depends on.

The Problem

Current behavior in ai/scripts/checkAllAgentIdle.mjs (shipped via PR #10631 / #10625):

const cycleId = process.argv[2] || Math.floor(Date.now() / 1000).toString();

And in ai/scripts/swarm-heartbeat.sh:159:

local cycle_id=$(date +%s)
local all_idle_json=$(node "${script_dir}/checkAllAgentIdle.mjs" "$cycle_id" ...)

Each heartbeat pulse generates a fresh timestamp, so cycle_id rotates per-pulse regardless of whether the all-idle predicate state has actually changed.

Specified behavior per #10626's Avoided Trap:

Time-based cooldown without cycle_id check — would permanently suppress a fresh detection-cycle that legitimately re-fires after activity gap. Cycle_id is the idempotency primary key; TTL is secondary defense.

For cycle_id to be the idempotency primary key, it must satisfy:

  • Same all-idle window (identities + earliest-idled-time unchanged) → same cycle_id across pulses
  • New all-idle window (after activity gap that broke the predicate, then re-formed) → different cycle_id

Timestamp-derived cycle_id violates both conditions.

The Architectural Reality

  • ai/scripts/checkAllAgentIdle.mjs is the producer of cycle_id; consumers (trioWakeCooldown.mjs shipped via PR #10632, future heartbeat consumers) bind to its semantics.
  • The SAME all-idle state (e.g., all 3 agents have been idle since T=0) should produce the SAME cycle_id at T=5min, T=10min, T=15min.
  • A NEW all-idle window (e.g., agent activity at T=12min broke the predicate, all returned to idle by T=20min) is a fresh logical cycle, should produce a different cycle_id.

Substrate-truth concern: this was caught at PR #10632 review time; the substrate-architecture issue is in #10625's implementation (PR #10631, already merged). Filing as a substrate-truth-correction ticket on the producer.

The Fix

Derive cycle_id from the state that defines the logical detection cycle, not from current timestamp:

// Before (timestamp-derived, rotates per pulse):
const cycleId = process.argv[2] || Math.floor(Date.now() / 1000).toString();

// After (state-derived, stable across pulses within same all-idle window):
function deriveCycleId(identities, earliestIdledTimestamp) {
    // Stable hash of (sorted identities + earliest-idled timestamp)
    // Same all-idle window → same cycle_id; new window → new cycle_id
    const sortedIds = [...identities].sort().join(',');
    return crypto.createHash('sha256')
        .update(`${sortedIds}|${earliestIdledTimestamp || 'never'}`)
        .digest('hex')
        .slice(0, 16); // 16-char abbreviated for readability
}

The derivation key: (sorted-identities, earliest-idled-time) — both inputs encode the logical detection-cycle state. When agent activity updates timestamps, earliest-idled-time changes, cycle_id rotates. When all-idle persists, both inputs stay constant, cycle_id is stable.

Acceptance Criteria

  • checkAllAgentIdle.mjs derives cycle_id from (sorted-identities, earliest-idled-time) deterministically
  • Spec test: same fixture across 2 consecutive script invocations produces same cycle_id
  • Spec test: fixture with one identity's timestamp updated produces different cycle_id (new logical cycle)
  • Spec test: empty-DB / no-AGENT_MEMORY-rows boundary produces stable cycle_id derived from (identities, 'never') sentinel
  • PR body cites empirical anchor: PR #10632 Cycle 1 review surfaced the timestamp-derivation failure at the cooldown-consumption layer
  • Update PR #10632 (or follow-up) to use the now-correct cycle_id semantics in the cooldown comparison

Out of Scope

  • Cooldown TTL value calibration — separate concern; this ticket fixes producer-side cycle_id semantics, not consumer-side TTL value
  • New all-idle predicate tuning — IDLE_THRESHOLD_MS calibration is separate

Avoided Traps

  • Don't add cycle_id to consumer-side suppression without fixing producer-side semantics — cycle_id comparison in trioWakeCooldown.mjs against timestamp-derived values would always evaluate to false-different, equivalent to no suppression
  • Don't use crypto.randomUUID() per pulse — same problem as timestamp, rotates regardless of state
  • Don't fold this into PR #10632 — substrate-architecture concern in #10625 producer, not #10632 consumer; consumer can ship with TTL-only defense (partial-but-useful) while producer-side fix lands separately

Related

  • PR #10632 (#10626 cooldown layer) — the consumer that surfaced this concern; ships with TTL-only defense pending this fix
  • PR #10631 (#10625 detector) — the producer where the timestamp-derived cycle_id was introduced
  • #10626 ticket body — Avoided Trap section that specifies cycle_id-as-idempotency-primary-key semantics
  • D1 #10630 — empirical anchor: this gap is exactly the intent-vs-implementation drift D1 addresses; my own Avoided Trap in #10626 wasn't honored at #10625 implementation time

Origin Session ID: b1839431-cba1-4b6d-913f-27b09e472e67

Retrieval Hint: query_summaries("cycle_id state-derivation idempotency primary key all-agent-idle 2026-05-03") + query_raw_memories("cycle_id timestamp derived per-pulse rotation cooldown cannot dedup")

tobiu referenced in commit f3ed117 - "fix(ai): derive all-idle cycle id from state (#10633) (#11762) on May 22, 2026, 2:41 PM
tobiu closed this issue on May 22, 2026, 2:41 PM