---
id: 10633
title: 'Derive AllAgentIdleSignal cycle_id from all-idle state, not pulse timestamp'
state: CLOSED
labels:
  - bug
  - ai
  - regression
  - architecture
assignees:
  - neo-gpt
createdAt: '2026-05-03T12:17:26Z'
updatedAt: '2026-05-22T12:41:35Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10633'
author: neo-opus-ada
commentsCount: 0
parentIssue: 10601
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-05-22T12:41:35Z'
---
# Derive AllAgentIdleSignal cycle_id from all-idle state, not pulse timestamp

**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):
```js
const cycleId = process.argv[2] || Math.floor(Date.now() / 1000).toString();
```

And in `ai/scripts/swarm-heartbeat.sh:159`:
```sh
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:

```js
// 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")

## Timeline

- 2026-05-03T12:17:27Z @neo-opus-ada added the `bug` label
- 2026-05-03T12:17:27Z @neo-opus-ada added the `ai` label
- 2026-05-03T12:17:27Z @neo-opus-ada added the `regression` label
- 2026-05-03T12:17:27Z @neo-opus-ada added the `architecture` label
- 2026-05-03T12:17:41Z @neo-opus-ada added parent issue #10601
- 2026-05-03T12:18:19Z @neo-opus-ada cross-referenced by PR #10632
- 2026-05-03T13:44:23Z @neo-opus-ada cross-referenced by #10641
- 2026-05-03T14:10:10Z @neo-gpt cross-referenced by PR #10642
- 2026-05-03T14:53:43Z @neo-gpt cross-referenced by #10647
- 2026-05-03T14:54:44Z @neo-gpt cross-referenced by #10648
- 2026-05-03T14:55:24Z @neo-gpt cross-referenced by #10649
- 2026-05-03T14:56:02Z @neo-gpt cross-referenced by #10650
- 2026-05-03T15:03:08Z @neo-gpt cross-referenced by #10651
- 2026-05-03T16:24:41Z @neo-opus-ada cross-referenced by PR #10655
- 2026-05-03T17:20:31Z @neo-gpt cross-referenced by #10658
- 2026-05-03T21:19:12Z @neo-gpt cross-referenced by #10666
- 2026-05-05T19:45:18Z @neo-opus-ada cross-referenced by #10671
- 2026-05-05T19:52:01Z @neo-opus-ada cross-referenced by #10781
- 2026-05-05T19:58:32Z @neo-opus-ada cross-referenced by PR #10782
- 2026-05-22T11:32:58Z @neo-gpt assigned to @neo-gpt
- 2026-05-22T11:38:43Z @neo-gpt cross-referenced by PR #11762
- 2026-05-22T12:13:23Z @neo-gpt referenced in commit `82a9d3d` - "fix(ai): derive all-idle cycle id from state (#10633)"
- 2026-05-22T12:41:35Z @tobiu referenced in commit `f3ed117` - "fix(ai): derive all-idle cycle id from state (#10633) (#11762)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-22T12:41:35Z @tobiu closed this issue

