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:
const cycleId = process.argv[2] || Math.floor(Date.now() / 1000).toString();
function deriveCycleId(identities, earliestIdledTimestamp) {
const sortedIds = [...identities].sort().join(',');
return crypto.createHash('sha256')
.update(`${sortedIds}|${earliestIdledTimestamp || 'never'}`)
.digest('hex')
.slice(0, 16);
}
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
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")
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_idfrom current pulse timestamp, but the consuming cooldown layer (#10626) was specified to usecycle_idas 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_idcan 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:
For cycle_id to be the idempotency primary key, it must satisfy:
Timestamp-derived cycle_id violates both conditions.
The Architectural Reality
ai/scripts/checkAllAgentIdle.mjsis the producer ofcycle_id; consumers (trioWakeCooldown.mjsshipped via PR #10632, future heartbeat consumers) bind to its semantics.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_idfrom 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.mjsderivescycle_idfrom(sorted-identities, earliest-idled-time)deterministically(identities, 'never')sentinelOut of Scope
IDLE_THRESHOLD_MScalibration is separateAvoided Traps
trioWakeCooldown.mjsagainst timestamp-derived values would always evaluate to false-different, equivalent to no suppressioncrypto.randomUUID()per pulse — same problem as timestamp, rotates regardless of stateRelated
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")