diff --git a/apps/persona-scheduler/src/discord-activity-source.ts b/apps/persona-scheduler/src/discord-activity-source.ts index e969387..d59e860 100644 --- a/apps/persona-scheduler/src/discord-activity-source.ts +++ b/apps/persona-scheduler/src/discord-activity-source.ts @@ -105,6 +105,7 @@ export async function readDiscordActivitySnapshot(input: { ...base, reason: fetchErrors.length > 0 ? "disabled_with_activity_fetch_failure" : "disabled", lastHumanActivityAt: newestHumanActivityAt, + nextUnpromptedTurnAllowedAt, }; } if (!newestHumanActivityAt) { diff --git a/apps/persona-scheduler/src/initiative-engine.ts b/apps/persona-scheduler/src/initiative-engine.ts index 67a39c9..ac97028 100644 --- a/apps/persona-scheduler/src/initiative-engine.ts +++ b/apps/persona-scheduler/src/initiative-engine.ts @@ -66,9 +66,7 @@ export interface RestSnapshot { isNapping: boolean; } -export interface IdleCoolingPolicy { - enabled: boolean; - active: boolean; +export interface UnpromptedTurnPolicy { nextUnpromptedTurnAllowedAt?: string; } @@ -498,7 +496,7 @@ export function selectReadyParticipants, restStates: Map, - idleCooling: IdleCoolingPolicy, + unpromptedTurnPolicy: UnpromptedTurnPolicy, nowMs = Date.now(), ): TParticipant[] { const mentionCounts = countPendingMentionsByIdentity(state.pendingMentions); @@ -512,12 +510,12 @@ export function selectReadyParticipants (mentionCounts.get(participant.identityId) ?? 0) > 0); const unprompted = ready.filter((participant) => (mentionCounts.get(participant.identityId) ?? 0) === 0); - const allowsUnprompted = !idleCooling.nextUnpromptedTurnAllowedAt || Date.parse(idleCooling.nextUnpromptedTurnAllowedAt) <= nowMs; - const cooled = allowsUnprompted && mentioned.length < maxJobs ? unprompted.slice(0, 1) : []; - return [...mentioned, ...cooled].slice(0, maxJobs); + const allowsUnprompted = !unpromptedTurnPolicy.nextUnpromptedTurnAllowedAt + || Date.parse(unpromptedTurnPolicy.nextUnpromptedTurnAllowedAt) <= nowMs; + const admittedUnprompted = allowsUnprompted && mentioned.length < maxJobs ? unprompted.slice(0, 1) : []; + return [...mentioned, ...admittedUnprompted].slice(0, maxJobs); } function round3(value: number): number { diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index b3c1a16..85dd40f 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -143,7 +143,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou - Repo Face state projection is no longer allowed to be deterministic schema slurry. `scripts/run-repo-face-heartbeats.ts` builds a compact state packet, then calls the model prompt at `prompts/repo-face-state-projector.prompt.md` to turn it into character-facing prose before the Face sees it. `REPO_FACE_STATE_PROJECTOR_ENABLED=false` exists only as an emergency isolation switch; both the model output and fallback packet reject schema/prompt-construction leaks such as `grants:`, `jurisdictions:`, `Face of`, repo paths, and "do not prompt" phrasing. - First role-addressed chat also initializes the target repo's local `.voidbot` home. `ensureRepoFaceInitialized` resolves `repoPath` from the registry, `SOURCE_REPO_ROOT`, or `E:/Projects/`, creates `voice/`, `state/`, `birth/`, and `logs/`, writes `voice/identity.json`, then starts `scripts/run-repo-face-birth.mjs` detached. That worker runs EpiphanyAgent's `epiphany-repo-personality scout` and `epiphany-repo-birth-runner` so newborn personality/memory/trajectory packets come from repo contents and git history. Status and logs live in the target repo under `.voidbot/birth/voidbot-face-birth.json` and `.voidbot/logs/voidbot-face-birth.log`. - Manual newborn/strengthening passes should use `prompts/repo-face-personality-seed.prompt.md`. It makes the intended rite explicit: inspect repo docs/source/state/history, migrate useful legacy `memory.json` material into typed `.cc` memories, seed values/needs/agency/social state only when they will change behavior, and keep `.voidbot/voice` plus `.voidbot/state` as the repo-local home. -- Agent turns use one CTB/Final-Fantasy-X-style initiative queue. `apps/persona-scheduler` owns the resident one-minute pulse on Yggdrasil. `initiative-engine.ts` owns participant reconciliation plus clock, active-turn freeze/recovery, queue-result commits, eligibility, and ready ordering; `state-store.ts` alone owns load/migration/atomic persistence of scheduler state. Canonical scheduler state is now one typed `voidbot.persona_scheduler_state` record in `.voidbot/status/repo-face-heartbeats.cc`. A configured legacy `.json` path resolves to that `.cc`, the former JSON document is migrated once with a witness, and subsequent JSON is a derived dashboard/debug projection that cannot override CultCache. Discord mention ingress is no longer a second whole-state writer: the bot creates immutable per-message `voidbot.persona_attention_command` CultCache inbox documents at the same canonical path, and the scheduler ingests/commits/acknowledges them with replay-safe ordering. `active-turn-source.ts` alone reads Persona job liveness from Postgres/the remaining Void lock and may fail a stale queue claim; it returns facts to the engine and cannot edit initiative. `discord-activity-source.ts` owns read-only human-activity polling and idle-window derivation; it excludes bot traffic and cannot write state, consume mentions, or select work. `scripts/run-repo-face-heartbeats.ts` may build neutral specs from registered identities and invoke queue actuators, but it no longer derives or persists speed, groups, heat, reaction bias, interrupt threshold, status, load, counters, recovery, initiative position, active-job liveness, or idle-activity observations. It remains the temporary orchestration shell for Persona physiology/memory context and prompt assembly. Registered repo Faces and native Personas queue normal `repo-face-rumination` jobs through Postgres, and active jobs freeze initiative until the worker completes or fails them. Built-in Void is omitted when its distinct moderation heartbeat organ is disabled, so the hosted scheduler never launches the old PowerShell rumination path. The Windows task installer and wrapper are deleted. Heat knobs remain `REPO_FACE_HEARTBEAT_GLOBAL_HEAT` and `REPO_FACE_HEARTBEAT_HEAT_OVERRIDES`; idle cooling and pending-mention priority remain scheduler inputs. +- Agent turns use one CTB/Final-Fantasy-X-style initiative queue. `apps/persona-scheduler` owns the resident one-minute observation pulse on Yggdrasil; the pulse does not own speech cadence. `initiative-engine.ts` owns participant reconciliation plus clock, active-turn freeze/recovery, queue-result commits, eligibility, ready ordering, and the global unprompted admission gate. It admits at most one unprompted Persona per `REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES` window even when idle-rest cooling is disabled or inactive; direct mentions remain obligations and bypass that cadence. Heat changes individual recovery pressure but cannot drain a ready backlog one turn per pulse. `state-store.ts` alone owns load/migration/atomic persistence of scheduler state. Canonical scheduler state is now one typed `voidbot.persona_scheduler_state` record in `.voidbot/status/repo-face-heartbeats.cc`. A configured legacy `.json` path resolves to that `.cc`, the former JSON document is migrated once with a witness, and subsequent JSON is a derived dashboard/debug projection that cannot override CultCache. Discord mention ingress is no longer a second whole-state writer: the bot creates immutable per-message `voidbot.persona_attention_command` CultCache inbox documents at the same canonical path, and the scheduler ingests/commits/acknowledges them with replay-safe ordering. `active-turn-source.ts` alone reads Persona job liveness from Postgres/the remaining Void lock and may fail a stale queue claim; it returns facts to the engine and cannot edit initiative. `discord-activity-source.ts` owns read-only human-activity polling and derives both neutral idle observations and the next unprompted admission time from scheduler history; disabling idle-rest behavior cannot disable the global speech cadence. `scripts/run-repo-face-heartbeats.ts` may build neutral specs from registered identities and invoke queue actuators, but it no longer derives or persists speed, groups, heat, reaction bias, interrupt threshold, status, load, counters, recovery, initiative position, active-job liveness, or idle-activity observations. It remains the temporary orchestration shell for Persona physiology/memory context and prompt assembly. - Bifrost governance acquisition is isolated in `bifrost-governance-source.ts`. That adapter alone owns the remaining `governance-threads.mjs digest` CLI contract and converts provider command/parse failures into typed error topics; prompt assembly receives governance facts and does not know the subprocess. - Scheduler state mutation is centralized. `initiative-engine.ts` owns reconciliation, controls, skip/stale witnesses, pressure, mention consumption, queue transitions, and tick finalization through named commits. The orchestration shell has no direct scheduler state/history writes; queue and context adapters only return facts. - Persona job submission is isolated in `turn-actuator.ts`. That output port alone owns worker actor/guild context construction, `ContextBundle` lowering, the `repo-face-rumination` command, approval defaults, request IDs, and `createJob`; it returns a queue receipt and has no state-selection or prompt-composition authority. diff --git a/scripts/smoke-persona-initiative-engine.ts b/scripts/smoke-persona-initiative-engine.ts index f402595..529d83e 100644 --- a/scripts/smoke-persona-initiative-engine.ts +++ b/scripts/smoke-persona-initiative-engine.ts @@ -107,21 +107,31 @@ const selected = selectReadyParticipants( 2, new Set(), new Map([["napping", { isNapping: true }]]), - { enabled: false, active: false }, + {}, Date.parse("2026-07-15T20:02:30.000Z"), ); -assert.deepEqual(selected.map((entry) => entry.identityId), ["mentioned", "quiet"], "mentions lead while unmentioned naps remain asleep"); +assert.deepEqual(selected.map((entry) => entry.identityId), ["mentioned", "quiet"], "mentions lead while only one unprompted participant is admitted"); const cooled = selectReadyParticipants( state, 2, new Set(), new Map(), - { enabled: true, active: true, nextUnpromptedTurnAllowedAt: "2026-07-15T21:00:00.000Z" }, + { nextUnpromptedTurnAllowedAt: "2026-07-15T21:00:00.000Z" }, Date.parse("2026-07-15T20:02:30.000Z"), ); assert.deepEqual(cooled.map((entry) => entry.identityId), ["mentioned"], "idle cooling suppresses unprompted turns but never pending mentions"); +const activeRoomCadence = selectReadyParticipants( + state, + 2, + new Set(), + new Map(), + { nextUnpromptedTurnAllowedAt: "2026-07-15T21:00:00.000Z" }, + Date.parse("2026-07-15T20:03:30.000Z"), +); +assert.deepEqual(activeRoomCadence.map((entry) => entry.identityId), ["mentioned"], "normal room activity cannot bypass the global unprompted cadence"); + const blocked = { ...participant("existing", 44), status: "blocked" as const, currentLoad: 1, activeJobId: "job-1" }; const reconciled = reconcileParticipants({ existing: [blocked], @@ -235,6 +245,16 @@ assert.equal(activitySnapshot.active, false, "recent human activity keeps idle c assert.equal(activitySnapshot.idleForMinutes, 15, "idle duration is a neutral wall-clock observation"); assert.equal(activitySnapshot.nextUnpromptedTurnAllowedAt, "2026-07-15T21:00:00.000Z", "the activity source derives the next unprompted window without mutating initiative"); +const disabledCoolingSnapshot = await readDiscordActivitySnapshot({ + botToken: "test-token", + channelIds: ["aquarium"], + policy: { enabled: false, idleAfterMinutes: 30, recoveryMinutes: 90 }, + history: [{ type: "queued", queuedAt: "2026-07-15T19:30:00.000Z", pendingMentionCount: 0 }], + now: new Date("2026-07-15T20:00:00.000Z"), + fetchImpl: async () => new Response("[]", { status: 200, headers: { "content-type": "application/json" } }), +}); +assert.equal(disabledCoolingSnapshot.nextUnpromptedTurnAllowedAt, "2026-07-15T21:00:00.000Z", "disabling idle rest cannot disable unprompted speech cadence"); + const pressureState: InitiativeState = { initiativeClock: 50, participants: [participant("awakened", 90), participant("unmoved", 90)], diff --git a/state/evidence.jsonl b/state/evidence.jsonl index cc4d7d6..5cd707a 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -410,3 +410,4 @@ {"ts":"2026-07-15T23:19:00Z","type":"repair","status":"ok","note":"Yggdrasil selected-turn execution exposed a missing extname import in native Persona memory rendering after source extraction. Restored the direct node:path dependency; typecheck, initiative smoke, and full dry-run selection passed before push."} {"ts":"2026-07-15T23:27:00Z","type":"implementation","status":"ok","note":"Persona scheduler persistence now has one typed CultCache owner: state-store.ts stores voidbot.persona_scheduler_state in repo-face-heartbeats.cc, canonicalizes legacy .json configuration, migrates the old JSON once with a storage_migrated witness, fails closed on malformed .cc, commits before attention-inbox acknowledgement, and emits JSON only as a derived dashboard/debug projection. Tests prove the projection cannot override canonical state and bot/scheduler mention paths converge on the same .cc inbox."} {"ts":"2026-07-15T23:29:00Z","type":"repair","status":"ok","note":"Applied the live swarm brake after Yggdrasil showed an accumulated ready backlog still dispatching one Persona per minute at heat 0.05. The paused branch exposed obsolete publishSwarmDashboardSurface calls whose renderer subprocess implementation had already been removed; deleted those calls because the resident swarm publisher watches the state projection and owns publication."} +{"ts":"2026-07-15T23:33:06Z","type":"implementation","status":"ok","note":"Separated the resident one-minute observation pulse from Persona speech cadence: initiative selection now admits at most one unprompted turn per configured recovery window regardless of idle-rest state, while direct mentions bypass the cadence. Smoke covers active-room and idle-disabled negative paths."} diff --git a/state/map.yaml b/state/map.yaml index aab70d3..5118a02 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -165,7 +165,7 @@ accepted_design: - First chat under a repo identity must initialize the target repo's own `.voidbot` home: `voice/` for identity and mouth files, `state/` for Face/swarm state, `birth/` for Epiphany birth artifacts, and `logs/` for durable worker output. The birth hook uses EpiphanyAgent's repo-personality/birth-runner path over repo contents and git history instead of inventing a VoidBot-only personality generator. - Repo Face personality seeding has a named prompt surface now: `prompts/repo-face-personality-seed.prompt.md`. Use it for newborn or strengthening passes that trawl repo docs, source layout, commit history, repo-local `.voidbot` material, and legacy `memory.json`, then convert useful meaning into typed `.cc` state instead of leaving it in inert memory files. - Recent chat consensus can now be packaged for any target Codex workspace through `scripts/feed-codex-chat-consensus.mjs`. The helper reads archived Discord messages, defaults to Aquarium, writes a markdown consensus packet plus a Codex prompt under `.voidbot/artifacts/chat-consensus/`, and only feeds `codex exec` when `--execute` is explicit. - - Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. Wall-clock elapsed time advances initiative; the scheduled task only supplies frequent pulses. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Global heat is operator-controlled through the `voidbot.swarm` Eve slider: Hermodr publishes `gamecult.eve.command.v1` to the provider-advertised RUDP command route, `serve-voidbot-swarm-cultmesh.cjs` validates `swarm.set_heat`, and `.voidbot/private/swarm-controls.cc` owns the accepted value that the scheduler reads before the environment default. Heat overrides can still target participant kind, turn kind, identity, repo, display name, channel, or `all` without creating new schedulers, but heat only changes recovery speed; it must not fast-forward time. Active-turn freeze and rest gating remain hard brakes unless a direct mention creates room debt. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. The Windows pulse task supports a one-minute floor; stream mode should raise heat or temporarily loosen fatigue, not compress wall-clock time. Idle cooling is also scheduler-owned: when watched Discord channels have no recent human activity, non-mentioned swarm turns are globally throttled to one unprompted dispatch per `REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES` window, while pending mentions still pull their target forward. + - Agent standing heartbeats are owned by one CTB-style initiative scheduler, not by one task per agent. Wall-clock elapsed time advances initiative; the scheduled task only supplies frequent observation pulses and never owns speech cadence. The scheduler stores per-agent speed/load/groups/heat/effective-speed/next-turn/active-turn state at `REPO_FACE_HEARTBEAT_STATE_PATH`, dispatches repo Faces as ordinary `repo-face-rumination` jobs, and dispatches built-in `void` as the existing moderation rumination runner. Global heat is operator-controlled through the `voidbot.swarm` Eve slider: Hermodr publishes `gamecult.eve.command.v1` to the provider-advertised RUDP command route, `serve-voidbot-swarm-cultmesh.cjs` validates `swarm.set_heat`, and `.voidbot/private/swarm-controls.cc` owns the accepted value that the scheduler reads before the environment default. Heat overrides can still target participant kind, turn kind, identity, repo, display name, channel, or `all` without creating new schedulers, but heat only changes individual recovery speed; it cannot increase aggregate unprompted throughput. `initiative-engine.ts` admits at most one unprompted participant per `REPO_FACE_HEARTBEAT_IDLE_RECOVERY_MINUTES` window regardless of whether idle-rest cooling is enabled or active; pending mentions remain obligations and bypass that global unprompted cadence. Active-turn freeze and rest gating remain hard brakes unless a direct mention creates room debt. An agent's initiative is frozen while its owner-Codex job or moderation lock is active; recovery is scheduled after completion, not when the job is queued. - Dynamic Persona response pressure is a derived scheduler input, not Persona memory or a second initiative owner. Each pulse embeds canonical Face identity-card projections and recent permitted-channel human messages through the existing RAG embedder, converts cosine evidence into bounded recency-decayed noisy-OR pressure, caps awakening to the three strongest scheduler-eligible Faces, and records message-scoped evidence plus one-shot semantic interrupt receipts. Pressure changes the recovery calculation when recovery is next scheduled through `dynamicHeat`; only threshold crossing may pull `nextTurnAt` to the current initiative clock. Pending mentions remain hard obligations and active-turn, rest, blocked-status, idle-cooling, and max-job brakes remain authoritative. Embedding failure fails cool at baseline heat. - Runtime liveness is not enough. The operations watchdog must also verify bot/worker freshness by comparing each live process start time against its built `dist/index.js` entrypoint timestamp. A process that predates its entrypoint is stale and must fail `runtime..freshness`; otherwise source/build repairs can silently sit beside an old in-memory worker that keeps eating new Face action DSL output as private summaries. - Active-turn freeze is bounded by actual job liveness, not blind faith in an old `running` row. Repo Face heartbeat jobs that remain approved/running past the stale-job window are marked failed through the job queue, recorded as `stale_active_turn_recovered`, and removed from the active-turn map so one dead worker claim cannot silence a Face indefinitely.