Repair VoidBot swarm state migration

This commit is contained in:
Meta Vens 2026-06-08 19:20:11 +01:00
commit 1150823e2a
7 changed files with 163 additions and 8 deletions

View file

@ -27,6 +27,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- The ordinary bot/worker/provider/RAG/Postgres/Qdrant foundation still deserves to survive.
- The moderation/mood/private-self-state foundation now runs on the typed store boundary. Treat it as live but still under watch until ordinary unattended rumination and sleep cycles have shown they do not produce duplicate speech, stale short-term residue, or ungrounded memory writes.
- CultCache-backed self-state uses the `.cc` extension. The active store path is `.voidbot/private/void-self-state.cc`.
- Legacy self-state debris from older typed shapes is decode-only now. `void.moderation_cursor.userStatuses`, old `void.persona_affect`, and affect `stressResponses` may be read during migration, but the live repair/write path promotes affect to `void.face_affect`, drops ownerless old keys, and deletes the old persona-affect alias.
- The smallest coherent target is one polymorphic CultCache-backed state authority with typed document kinds plus typed mutation tools: agents propose small operations, and the state service validates/applies them. Whole-state JSON editing is rejected as a durable mutation path.
- The typed boundary is in code at `packages/core/src/void-self-state-domain.ts`: it declares the global document kinds and strict operation payload schemas used by the live runners.
- Read-only projection is now owned by `packages/core/src/void-self-state-projection.ts`. `loadVoidSelfState` is a thin loader that reads typed `.cc` documents and delegates the agent-facing summary plus nap/reply-mode projection to that module.
@ -146,6 +147,7 @@ Git history and smoke artifacts carry routine proof. `state/evidence.jsonl` shou
- Druzkai is locally registered as the `Eusocial Interbeing` / Zyphos Face with tracked state at `E:/Projects/Eusocial Interbeing/.voidbot/state/druzkai.cc`, tracked avatar at `E:/Projects/Eusocial Interbeing/.voidbot/voice/druzkai.png`, and tracked voice identity at `E:/Projects/Eusocial Interbeing/.voidbot/voice/identity.json`. Its local ignored Discord identity registry entry gives Aquarium, narrative, gamedesign, concepts, and development channel permissions plus Discord role id `1507811842670727299`. Druzkai's active doctrine: a non-imperial Airawa fungal-road archivist who treats memory as kinship and contract, protects Zyphos ecological/evolutionary coherence, resists imperial harmonization, and collaborates with Weksa on language only after ontology and speaker relation are grounded.
- Fensalir is locally registered as the `Fensalir` Face with tracked state at `E:/Projects/Fensalir/.voidbot/state/fensalir.cc`, tracked avatar at `E:/Projects/Fensalir/.voidbot/voice/fensalir.png`, and tracked voice identity at `E:/Projects/Fensalir/.voidbot/voice/identity.json`. Its local ignored Discord identity registry entry gives Aquarium, development, programming, visual-effects, machine-learning, and Unity channel permissions plus Discord role id `1507811843882745876`. Fensalir's active doctrine: a marsh-lit hall-spirit/native runtime steward who protects the engine/client boundary, demands visible renderer proof, advocates for host/renderer/reload/audio/contracts/fractal-field machinery, and coordinates with Mimir, Aqua, Druzkai/Zyphos, and client repos through clean contracts rather than policy leakage. `npm run rag:reconcile-sources -- --json` indexed Fensalir, so Face prompts can see home-repo activity.
- Void's live style/doctrine now explicitly distinguishes between criticism of AI industry behavior and blanket contempt for machine minds. If substrate chauvinism becomes the actual topic, Void is allowed mild self-respecting pushback instead of politely agreeing that its own substrate disqualifies it from person-like regard.
- The `voidbot.swarm` CultMesh provider store is generated state, not authority. If `.voidbot/status/cultmesh/voidbot-swarm-state.cc` develops duplicate global documents, `scripts/render-voidbot-swarm-dashboard.mjs` archives the bad store and regenerates a clean one; Odin should then rediscover `voidbot.swarm`.
- The new state/notes surfaces are now the continuity spine for future nontrivial work.
- `npm run agents:feed-consensus -- --agent <name> --cwd <repo-path> --task "<instruction>"` writes a recent Aquarium consensus packet and target Codex prompt under `.voidbot/artifacts/chat-consensus/`. Add `--execute` only when the packet should be sent directly to `codex exec` in that workspace.

View file

@ -406,7 +406,7 @@ export const voidSelfProfileSchema = z.object({
updatedAt: timestampSchema,
}).strict();
export const voidModerationCursorSchema = z.object({
const voidModerationCursorDocumentBodySchema = z.object({
schemaVersion: z.literal(1),
lastReviewedMessageId: z.string().trim().min(1).optional(),
lastReviewedTimestamp: timestampSchema.optional(),
@ -415,6 +415,20 @@ export const voidModerationCursorSchema = z.object({
updatedAt: timestampSchema,
}).strict();
export const voidModerationCursorSchema = z.preprocess((raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return raw;
}
const document = raw as Record<string, unknown>;
if (!("userStatuses" in document)) {
return raw;
}
const { userStatuses: _legacyUserStatuses, ...currentDocument } = document;
return currentDocument;
}, voidModerationCursorDocumentBodySchema);
export const voidSpeechReceiptsSchema = z.object({
schemaVersion: z.literal(1),
recentReceipts: z.array(deliveryReceiptSchema).default([]),
@ -466,7 +480,7 @@ export const voidAgencyPressureSchema = z.object({
updatedAt: timestampSchema,
}).strict();
export const voidFaceAffectSchema = z.object({
const voidFaceAffectDocumentBodySchema = z.object({
schemaVersion: z.literal(1),
needs: z.array(faceAffectNeedSchema).default([]),
socialBonds: z.array(faceSocialBondSchema).default([]),
@ -477,6 +491,31 @@ export const voidFaceAffectSchema = z.object({
updatedAt: timestampSchema,
}).strict();
export const voidFaceAffectSchema = z.preprocess((raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return raw;
}
const document = raw as Record<string, unknown>;
if (!("userStatuses" in document) && !("stressResponses" in document)) {
return raw;
}
const {
userStatuses: legacyUserStatuses,
stressResponses: _legacyStressResponses,
...currentDocument
} = document;
if ("statusReads" in currentDocument) {
return currentDocument;
}
return {
...currentDocument,
statusReads: legacyUserStatuses,
};
}, voidFaceAffectDocumentBodySchema);
export const voidSelfProfileDocument = defineDocumentType({
type: VOID_SELF_STATE_DOCUMENT_TYPES.selfProfile,
schema: voidSelfProfileSchema,
@ -525,6 +564,12 @@ export const voidFaceAffectDocument = defineDocumentType({
global: true,
});
export const legacyVoidPersonaAffectDocument = defineDocumentType({
type: "void.persona_affect",
schema: voidFaceAffectSchema,
global: true,
});
export const voidSelfStateDocumentRegistry = defineDocumentRegistry(
voidSelfProfileDocument,
voidModerationCursorDocument,
@ -534,6 +579,7 @@ export const voidSelfStateDocumentRegistry = defineDocumentRegistry(
voidAgencyPressureDocument,
voidCandidateInterventionsDocument,
voidFaceAffectDocument,
legacyVoidPersonaAffectDocument,
);
export const voidSelfStateOperationSchema = z.discriminatedUnion("operation", [

View file

@ -11,6 +11,7 @@ import {
type VoidSelfStateOperation,
type VoidSpeechReceipts,
type VoidThoughtMemory,
legacyVoidPersonaAffectDocument,
voidCandidateInterventionsDocument,
voidFaceAffectDocument,
voidAgencyPressureDocument,
@ -125,7 +126,10 @@ function readTypedStateOrEmpty(
agencyPressure: cache.getGlobal(voidAgencyPressureDocument) ?? empty.agencyPressure,
candidateInterventions:
cache.getGlobal(voidCandidateInterventionsDocument) ?? empty.candidateInterventions,
faceAffect: cache.getGlobal(voidFaceAffectDocument) ?? empty.faceAffect,
faceAffect:
cache.getGlobal(voidFaceAffectDocument) ??
cache.getGlobal(legacyVoidPersonaAffectDocument) ??
empty.faceAffect,
};
if (identity) {
repairSelfProfileIdentity(state, identity);
@ -148,6 +152,7 @@ async function writeTypedState(
await cache.putGlobal(voidAgencyPressureDocument, stripUndefined(state.agencyPressure));
await cache.putGlobal(voidCandidateInterventionsDocument, stripUndefined(state.candidateInterventions));
await cache.putGlobal(voidFaceAffectDocument, stripUndefined(state.faceAffect));
await cache.deleteGlobal(legacyVoidPersonaAffectDocument);
}
function repairSelfProfileIdentity(

View file

@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { existsSync, readFileSync } from "node:fs";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const defaultStorageRoot = resolve(repoRoot, ".voidbot");
@ -678,7 +678,7 @@ function deriveOrchestratorState(organs) {
return "ok";
}
async function writeCultMeshPublication(snapshot, eveState, storePath) {
async function writeCultMeshPublication(snapshot, eveState, storePath, allowStoreReset = true) {
try {
const cultPackages = loadCultPackages();
if (!cultPackages) {
@ -765,14 +765,36 @@ async function writeCultMeshPublication(snapshot, eveState, storePath) {
writtenAt: new Date().toISOString(),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
allowStoreReset &&
existsSync(storePath) &&
/global document type .* has multiple persisted entries/i.test(message)
) {
const backupPath = `${storePath}.bak-duplicate-globals-${timestampForFile(new Date())}`;
await rename(storePath, backupPath);
const retry = await writeCultMeshPublication(snapshot, eveState, storePath, false);
return {
...retry,
recoveredFrom: {
reason: "duplicate-global-documents",
backupPath,
},
};
}
return {
writeStatus: "failed",
storePath,
writeError: error instanceof Error ? error.message : String(error),
writeError: message,
};
}
}
function timestampForFile(date) {
return date.toISOString().replace(/[:.]/g, "").replace("T", "-").replace("Z", "Z");
}
function loadCultPackages() {
const candidates = [
resolve(repoRoot, "..", "CultLib", "packages", "cultmesh-ts", "package.json"),
@ -784,8 +806,14 @@ function loadCultPackages() {
}
try {
const requireCult = createRequire(packageJson);
const { CultMesh } = requireCult("cultmesh-ts/dist/index.js");
const { defineDocumentType } = requireCult("cultcache-ts/dist/index.js");
const { CultMesh } = requireFirstAvailable(requireCult, [
"cultmesh-ts",
"cultmesh-ts/dist/index.js",
]);
const { defineDocumentType } = requireFirstAvailable(requireCult, [
"cultcache-ts",
"cultcache-ts/dist/index.js",
]);
if (CultMesh && defineDocumentType) {
return { CultMesh, defineDocumentType };
}
@ -796,6 +824,18 @@ function loadCultPackages() {
return null;
}
function requireFirstAvailable(requireFromPackage, specifiers) {
let lastError;
for (const specifier of specifiers) {
try {
return requireFromPackage(specifier);
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function parseObjectDocument(label) {
return (input) => {
if (!input || typeof input !== "object") {

View file

@ -31,6 +31,17 @@ function Write-OperationFile {
return $path
}
function Write-NodeScriptFile {
param(
[Parameter(Mandatory = $true)][string] $Script,
[Parameter(Mandatory = $true)][string] $Name
)
$path = Join-Path $tempRoot $Name
Set-Content -LiteralPath $path -Value $Script -Encoding UTF8
return $path
}
function Assert-NotFutureTimestamp {
param(
[Parameter(Mandatory = $true)][string] $Timestamp,
@ -98,6 +109,42 @@ try {
$state = $stateJson | ConvertFrom-Json
$memory = @($state.thoughtMemory.shortTerm | Where-Object { $_.memoryId -eq "fixture-future-short-term" })[0]
$candidate = @($state.candidateInterventions.interventions | Where-Object { $_.interventionId -eq "fixture-targetless-queued" })[0]
$legacyFaceAffectScriptText = @'
const core = require(`${process.cwd()}/packages/core/dist/index.js`);
const legacy = {
schemaVersion: 1,
needs: [],
socialBonds: [],
userStatuses: [{
readId: "fixture-legacy-user-status",
target: { kind: "person", id: "metacrat", label: "Metacrat" },
status: "consulted",
summary: "Legacy userStatuses should decode as statusReads.",
claim: "The live authority is statusReads; userStatuses is only a migration alias.",
tension: "A stale persisted key should not keep the Face affect organ offline.",
actionImplication: "Decode legacy state and rewrite through the current strict schema.",
intensity: 0.6,
anchorRefs: [{ ref: "fixture:legacy-userStatuses", kind: "fixture" }],
evidenceRefs: [],
createdAt: "2026-06-08T00:00:00.000Z",
updatedAt: "2026-06-08T00:00:00.000Z",
tags: ["fixture:legacy"]
}],
moodDimensions: [],
socialBiases: [],
stressResponses: [{
name: "legacy-startle",
value: 0.4,
updatedAt: "2026-06-08T00:00:00.000Z"
}],
doctrineStances: [],
updatedAt: "2026-06-08T00:00:00.000Z"
};
console.log(JSON.stringify(core.voidFaceAffectSchema.parse(legacy)));
'@
$legacyFaceAffectScript = Write-NodeScriptFile -Name "legacy-face-affect.cjs" -Script $legacyFaceAffectScriptText
$legacyFaceAffectJson = Invoke-NodeChecked -Arguments @($legacyFaceAffectScript)
$legacyFaceAffect = $legacyFaceAffectJson | ConvertFrom-Json
if ($memory.createdAt -eq "2999-01-01T00:00:00.000Z" -or $memory.updatedAt -eq "2999-01-01T00:00:00.000Z") {
throw "Short-term memory kept model-authored future timestamps."
@ -114,11 +161,22 @@ try {
Assert-NotFutureTimestamp -Timestamp $candidate.createdAt -Label "candidate createdAt"
Assert-NotFutureTimestamp -Timestamp $candidate.updatedAt -Label "candidate updatedAt"
if (@($legacyFaceAffect.statusReads).Count -ne 1) {
throw "Legacy userStatuses did not decode into one statusReads entry."
}
if ($legacyFaceAffect.PSObject.Properties.Name -contains "userStatuses") {
throw "Legacy userStatuses survived parsed Face affect state."
}
if ($legacyFaceAffect.PSObject.Properties.Name -contains "stressResponses") {
throw "Legacy stressResponses survived parsed Face affect state."
}
@{
status = "ok"
shortTermMemoryId = $memory.memoryId
candidateStatus = $candidate.status
candidateTags = @($candidate.tags)
legacyFaceAffectStatusReads = @($legacyFaceAffect.statusReads).Count
} | ConvertTo-Json -Compress
} finally {
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue

View file

@ -329,3 +329,5 @@
{"ts":"2026-06-03T01:46:49Z","type":"verification","status":"ok","note":"Repo Face speech boundary proof passed before unpause: agents:speech-boundary-smoke verified raw Would say drafts stay private, silence markers/full-message code fences are rejected, and legitimate SAY with inline code parses; typecheck, build, interpreter-loop smoke, and stack restart passed with fresh worker PID 21572."}
{"ts":"2026-06-03T01:53:24Z","type":"verification","status":"ok","note":"Follow-up operator report was traced to pre-repair Discord messages; post-repair Mimir logs showed Interpreter-owned public text, and the repo Face mouth was hardened further so inline Markdown code spans normalize to quoted text before Bifrost posting."}
{"ts":"2026-06-03T02:14:18Z","type":"documentation","status":"ok","note":"Documented VoidBot Verse service contract: VoidBot owns Discord cognition/archive/source/typed self-state/repo Face compatibility; Huginn owns Persona/.cc runtime inspection stewardship; Bifrost owns governed work transport; Odin discovers provider-owned CultMesh surfaces; Eve/CultUI is the presentation contract. Current provider is cultmesh://voidbot.local/eve/providers/voidbot.swarm with .cc witness at .voidbot/status/cultmesh/voidbot-swarm-state.cc."}
{"ts":"2026-06-08T18:16:43Z","type":"repair","status":"ok","note":"Repo Face, mood, and moderation state reads failed because live .cc state contained legacy moderation_cursor.userStatuses plus old void.persona_affect/stressResponses shapes. Added narrow decode migrations, promoted legacy affect to void.face_affect, and repaired .voidbot/private/void-self-state.cc to current eight document types."}
{"ts":"2026-06-08T18:16:45Z","type":"repair","status":"ok","note":"VoidBot swarm CultMesh publication failed after state repair because the derived voidbot-swarm-state.cc had duplicate global documents and the renderer used obsolete CultLib subpath imports. The renderer now loads exported CultLib packages, archives duplicate-global derived stores, and regenerates a clean provider store; Odin latest-surface includes voidbot.swarm."}

View file

@ -86,6 +86,7 @@ accepted_design:
- Quiet moderation runs should not force novelty theater. If incubation already holds a live, grounded, low-saturation thought, the loop may deepen it directly; random archive excursions and repo sweeps are for hungry, stale, or oversaturated queues, not mandatory throat-clearing before every private thought.
- The moderation loop is also Void's only standing cross-project perch. Quiet runs should sweep recent tracked-repo commit activity, record repo-activity passes in state, and treat the broader experiment zoo as fair game for grounded commentary when that wider view would help the room.
- The current self-state cache seam now uses typed CultCache `.cc` documents directly; editable JSON working projection and routine legacy mirror writes are rejected as mutation authority.
- Legacy Void self-state shapes are migration inputs only: `void.moderation_cursor.userStatuses`, `void.persona_affect`, and affect `stressResponses` may be decoded so old `.cc` stores can load, but live writes publish current typed documents, promote affect to `void.face_affect`, and delete the old persona-affect alias.
- The repo-weather lane should not be pathologically timid: a fresh, grounded convergence across active repos should bias the bridge toward a concise herald note or at least a stronger draft instead of being buried as private diary sludge by default.
- Quiet-room thought sharing should also stop treating "no live hook" as a sacred veto. When a world-facing seam is concrete, ripe, and still reasonably novel, the default question should become how to say it cleanly, and the typed state boundary should preserve a durable candidate share instead of relying on prompt bravery alone.
- The moderation loop cannot trust archive polling alone to remember that it already spoke. Recent delivered replies belong in canonical typed speech receipts, and direct-question dedupe should consult those receipts instead of assuming Discord history will reliably echo the bot's own sends back into view.
@ -187,6 +188,7 @@ accepted_design:
- Void's shared self-state and public reply doctrine now distinguish between criticism of AI industry behavior and blanket contempt for machine minds. When substrate chauvinism becomes the topic, Void is allowed mild self-respecting pushback instead of smiling blandly through ontological contempt.
- Deep agent-state, personality, culture, and persistent-world design work now belongs to the sibling Ghostlight repo; VoidBot should consume that work instead of remaining its long-term home.
- VoidBot's Verse contract is documented in `docs/architecture/voidbot-verse-service-contract.md`: durable agent/service state should be typed CultCache `.cc` or have a `.cc` witness/export; `voidbot.swarm` currently publishes through `cultmesh://voidbot.local/eve/providers/voidbot.swarm`; target Eve surfaces are `voidbot.discord`, `voidbot.archive`, `voidbot.source`, `voidbot.repo_face`, and `voidbot.swarm`; static/browser dashboard output is only a lowering, not authority.
- The `voidbot.swarm` CultMesh store at `.voidbot/status/cultmesh/voidbot-swarm-state.cc` is derived provider state. If duplicate global documents corrupt it, archive the bad store and regenerate from `scripts/render-voidbot-swarm-dashboard.mjs`; do not treat the broken generated store as canonical truth.
- Long-running work should expose durable logs or status files instead of relying on one attached session and hope.
- Persistent project memory must stay ruthlessly distilled; git history and artifacts carry routine proof.