feat(missions): bound journal capacity per mission, not per project (#1178)

Step 4 of 6 toward removing the Missions limits (#1167 follow-up), on
top of steps 1-3 (#1175, #1176, #1177).

## Change
- **Per-mission capacity:** each mission holds at most 2,000 ordinary
entries plus its own 36-entry deletion reserve (2,036 per range). **No
project-wide cap.**
- Capacity checks and appends read only the target mission's range (no
full scan). Safety decisions keep their full reads (accepted: their cost
grows with history).
- Reserve: only the mission's own tombstone and already reserved
receipts; works on every storage.
- Full project reads bounded per range (and stray entries together);
memory = per-mission bound × missions, documented trade-off.
- Purge is retention-only (20 newest settled deletions kept);
capacity-pressure purge, creation pre-check and related re-checks
removed.
- Existing mission data is negligible (projects were capped at 20
missions, barely used): **no legacy, migration or rollback handling by
design.**
- Docs: `MISSIONS.md` safety limits; help sentence on freeing storage
updated in 10 locales.

## Validation
- Read-budget test at 10,000 history entries; project beyond one
mission's bound fully works; capacity checks read only their range; full
mission refuses ordinary entries/new Stop but deletes through its
reserve (with and without removal). Each red with its guarantee mutated.
- Native stage: 2,200 entries across 100 finished missions on an
isolated runtime, then create/declare/readout/delete with retention
purge.
- Server and UI typecheck; 1,508 server tests; `test:help`;
`test-missions-native.mjs` on isolated OpenCode 2.2.0 and 2.1.4.
This commit is contained in:
Pascal André 2026-10-28 15:44:19 +01:00 • committed by GitHub
parent b38af07fbe
commit 681b854865
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 268 additions and 171 deletions

View file

@ -258,11 +258,11 @@ Mission Control manages map metadata through the authenticated CodeNomad routes
Deletion optionally accepts `deleteManagedSessions: true`. Immutable targets are recorded in the tombstone, and per-session receipts reserve journal capacity before removal. Only deterministically created specialist roots with matching native creation metadata and exact project/location identity qualify. Coordinators, reused sessions, actors referenced by another mission (including finished/deleted maps), and roots with children are retained. Native removal is recursive, so descendants are outside this opt-in. The plugin delegates removal to the authenticated desktop bridge, because native plugin contexts lack `session.list/remove`. The bridge obtains authoritative intent through the narrow read-only `cleanupTarget` RPC, revalidates ownership/connection/worktree fences and calls the native HTTP client. It never modifies OpenCode storage directly. An incomplete cleanup returns `503 cleanup-pending` after the tombstone is committed; replay the exact original request ID, revision and option. The editor retains that request through live snapshot refreshes and locks the option after the first attempt. Only an explicit retry resumes cleanup.
**Purging deleted missions** (`missions/journal-purge.ts`). Once every cleanup target of a tombstone has its receipt, the journal removes that mission's events through the optional `MissionStorage.remove` (native `storage.remove`); storage without it keeps every entry. Deletion keeps the 20 newest settled deletions (`SETTLED_CLEANUP_HISTORY`: the cleanup history and the editor's lost-acknowledgement check read them) and purges older ones. When an attempted deletion finds neither its map nor its cleanup, the editor's explicit retry resubmits the original request, revision and option, and the record below answers it; passive refreshes only read; an append or capacity check that would exceed the limit first purges every eligible deletion. A deleted mission stays while it references, as coordinator or task actor, a session another unsettled mission could still clean up (a deterministic task root of a non-deleted mission, or a pending tombstone target): that reference is the shared-membership evidence cleanup retains such sessions on. Damaged journals (discarded entries, invalid cleanup, control or notification history) are never purged.
**Purging deleted missions** (`missions/journal-purge.ts`). Once every cleanup target of a tombstone has its receipt, the journal removes that mission's events through the optional `MissionStorage.remove` (native `storage.remove`); storage without it keeps every entry. Deletion keeps the 20 newest settled deletions (`SETTLED_CLEANUP_HISTORY`: the cleanup history and the editor's lost-acknowledgement check read them) and purges older ones. When an attempted deletion finds neither its map nor its cleanup, the editor's explicit retry resubmits the original request, revision and option, and the record below answers it; passive refreshes only read. Purging is retention only: capacity is per mission (§Safety limits), so a full mission makes room through its own deletion reserve and other missions never wait on a purge. A deleted mission stays while it references, as coordinator or task actor, a session another unsettled mission could still clean up (a deterministic task root of a non-deleted mission, or a pending tombstone target): that reference is the shared-membership evidence cleanup retains such sessions on. Damaged journals (discarded entries, invalid cleanup, control or notification history) are never purged.
Before the first removal the purge writes a deletion record at `codenomad-missions/v2/deleted/<projectToken>/<missionID>`, beside the scanned journal prefix: it costs no journal capacity, is only point-read (by every storage adapter, with or without removal support; the isolated recurrence-passage adapter, which never purges, has none) and is never removed. It holds the deletion request ID, revision and option plus the settled cleanup summary (240-character objective label, counts, reasons). The record refuses the mission's deterministic recreation (create, prepared hand-offs and inspect-start replays answer `mission-deleted` before any native effect) and answers an exact deletion replay with the original summary; another request ID answers `mission-not-found`, a changed revision or option `request-conflict`. Removal starts with `mission.created`, then tombstones, then intents before receipts. A group without its creation event whose mission has a valid record leaves the observation, so every intermediate state reads without discarded events, reopened cleanup or reservations; late writes to a purged mission are absorbed the same way and removed by the next purge, which also resumes an interrupted one. Without a readable record such a group stays damage evidence. Per-mission reads (`missionEvents()`/`missionView()`) scan only that mission's key range `<prefix>/<missionID>/` (the isolated recurrence-passage adapter accepts its own mission's range besides the whole virtual prefix) with the same parsing, read bound and record handling; their discarded count and damage flags are that range's own, and the snapshot's project values aggregate them plus entries outside every mission range. A paginated observation (`events()`/`snapshot()` or a per-mission read) takes the same `append:<projectToken>` exclusion as appends and purges, so it never assembles pages from both sides of a removal; code already holding it uses the private unlocked observer, and storage adapters never read the journal. Builds without purge support read a journal mid-purge as damaged, one using the deletion reserve as over its limit, and never consult deletion records.
Before the first removal the purge writes a deletion record at `codenomad-missions/v2/deleted/<projectToken>/<missionID>`, beside the scanned journal prefix: it costs no journal capacity, is only point-read (by every storage adapter, with or without removal support; the isolated recurrence-passage adapter, which never purges, has none) and is never removed. It holds the deletion request ID, revision and option plus the settled cleanup summary (240-character objective label, counts, reasons). The record refuses the mission's deterministic recreation (create, prepared hand-offs and inspect-start replays answer `mission-deleted` before any native effect) and answers an exact deletion replay with the original summary; another request ID answers `mission-not-found`, a changed revision or option `request-conflict`. Removal starts with `mission.created`, then tombstones, then intents before receipts. A group without its creation event whose mission has a valid record leaves the observation, so every intermediate state reads without discarded events, reopened cleanup or reservations; late writes to a purged mission are absorbed the same way and removed by the next purge, which also resumes an interrupted one. Without a readable record such a group stays damage evidence. Per-mission reads (`missionEvents()`/`missionView()`) scan only that mission's key range `<prefix>/<missionID>/` (the isolated recurrence-passage adapter accepts its own mission's range besides the whole virtual prefix) with the same parsing, read bound and record handling; their discarded count and damage flags are that range's own, and the snapshot's project values aggregate them plus entries outside every mission range. A paginated observation (`events()`/`snapshot()` or a per-mission read) takes the same `append:<projectToken>` exclusion as appends and purges, so it never assembles pages from both sides of a removal; code already holding it uses the private unlocked observer, and storage adapters never read the journal. Builds without purge support read a journal mid-purge as damaged and never consult deletion records.
**Journal index** (`missions/journal-index.ts`). Each `codenomad.missions` activation (`opencode/missions/desktop-plugin.ts`) owns one never-persisted `MissionJournalIndex`, shared by its ordinary journals (MissionControl through `setupMissionsPlugin`, presence retention) and disposed with it; never make it module-global, since a reloaded revision may reuse the module. `MissionJournal.readHints(read)` builds it on first use with one key-ordered streaming scan (same read bound as `observe()`), then runs `read` synchronously under the append exclusion on hints: `refs(sessionID)` (missions whose events name the session as coordinator, `preparedBy` author, dispatching or native-bound actor; live maps in snapshot order, ties by the first event then binary key order, then the rest in key order), `openIDs()` (per-range `missionWorkRemains`), `aggregate()` (the snapshot's discarded count, damage flags including entries outside every range, and latest live `updatedAt`) and `cleanups()` (the snapshot's displayed cleanup history: pending, then the 20 newest settled). The callback only extracts values: its type refuses a Promise or thenable result and a thenable is rejected at runtime before the exclusion could adopt it; range reads (`missionView`) happen after `readHints` resolves. Entries keep scalar ordering tokens and bounded labels, never events. Every ordinary journal write attempt (append, record write, purge removal), before it starts and whether it then fails, lands or goes unconfirmed, bumps a per-project write generation shared by all incarnations in the process and marks its mission dirty, so the next read re-reads that range; a range whose deletion record was unreadable stays dirty. A generation the index did not produce itself (another current-build activation, an unindexed journal) or any read failure makes it rebuild. Writers outside that generation stay unseen until the next rebuild: an older build activated in another native Location of the same project (native activation is per Location, its plugin storage per plugin ID, and native KV has no change counter), a failed-reload fallback, another process, bytes written without `MissionJournal`. A full rebuild at least every 60 seconds (`MISSION_INDEX_STALE_AFTER_MS`) heals such misses. The answers are therefore hints: anything that authorizes a write or native effect (cleanup authority and shared-session checks, membership admission or refusal, deletion, purge planning, recreation checks, damage gates before a write) keeps its authoritative full read; only read-mostly paths (the per-turn context hook, panel lists and summaries, outbox enumeration, and presence retention when the hints show open work; an empty answer is confirmed by a full read before presence is released) may use the hints. `MissionControl.hintedMissions` is that read: it takes candidate IDs from the hints, re-reads each range with `missionView`, keeps snapshot order (stable by `updatedAt`, ties in hint order) and stamps the hints' project damage flags on each map as a snapshot does; isolated passage journals use their snapshot instead. Its callers: the context hook (`contextFor`, one membership read per model turn returning the instruction and whether the session coordinates the mission; a conversation no mission names reads no range), `mission.inspect` without `start` (by `missionID` reads exactly that range), `inspect({ list })`, the task observation and decision-candidate reads behind `inspect({ taskKey })`, and the notification outbox's enumeration over `openIDs()` (every running mission is open). Each notification then re-reads the whole journal before its native admission and before its receipt, and both the native message ID and the receipt ID are deterministic, so a mission the hints miss is only delayed until the rebuild and a stale candidate is skipped once the full read shows it admitted. The context of a mission written by such an unseen writer is withheld until the rebuild; its tools stay offered and every one re-selects authoritatively. `retainMissionWork` retains on `hintsShowWork` (an open mission or project damage) and otherwise decides on the full snapshot. Creation and `inspect({ start })`, declaration, delegation, revision, briefing, report, update, lifecycle, recovery, prepare, task interruption, deletion and cleanup keep their full reads; the change events after lifecycle and deletion carry the revision those operations already read. `journal-read-budget.test.ts` pins both sides over a finished history filling the project cap: the hinted paths make no full scan and read at most their own ranges, while membership refusals and damage gates see entries the hints have not. Isolated recurrence-passage storage (`MissionStorage.isolated`) writes its own physical namespace, never counts and has no index (`native-passage-business.test.ts` traps ordinary journal writes from passages).
**Journal index** (`missions/journal-index.ts`). Each `codenomad.missions` activation (`opencode/missions/desktop-plugin.ts`) owns one never-persisted `MissionJournalIndex`, shared by its ordinary journals (MissionControl through `setupMissionsPlugin`, presence retention) and disposed with it; never make it module-global, since a reloaded revision may reuse the module. `MissionJournal.readHints(read)` builds it on first use with one key-ordered streaming scan (same read bound as `observe()`), then runs `read` synchronously under the append exclusion on hints: `refs(sessionID)` (missions whose events name the session as coordinator, `preparedBy` author, dispatching or native-bound actor; live maps in snapshot order, ties by the first event then binary key order, then the rest in key order), `openIDs()` (per-range `missionWorkRemains`), `aggregate()` (the snapshot's discarded count, damage flags including entries outside every range, and latest live `updatedAt`) and `cleanups()` (the snapshot's displayed cleanup history: pending, then the 20 newest settled). The callback only extracts values: its type refuses a Promise or thenable result and a thenable is rejected at runtime before the exclusion could adopt it; range reads (`missionView`) happen after `readHints` resolves. Entries keep scalar ordering tokens and bounded labels, never events. Every ordinary journal write attempt (append, record write, purge removal), before it starts and whether it then fails, lands or goes unconfirmed, bumps a per-project write generation shared by all incarnations in the process and marks its mission dirty, so the next read re-reads that range; a range whose deletion record was unreadable stays dirty. A generation the index did not produce itself (another current-build activation, an unindexed journal) or any read failure makes it rebuild. Writers outside that generation stay unseen until the next rebuild: an older build activated in another native Location of the same project (native activation is per Location, its plugin storage per plugin ID, and native KV has no change counter), a failed-reload fallback, another process, bytes written without `MissionJournal`. A full rebuild at least every 60 seconds (`MISSION_INDEX_STALE_AFTER_MS`) heals such misses. The answers are therefore hints: anything that authorizes a write or native effect (cleanup authority and shared-session checks, membership admission or refusal, deletion, purge planning, recreation checks, damage gates before a write) keeps its authoritative full read; only read-mostly paths (the per-turn context hook, panel lists and summaries, outbox enumeration, and presence retention when the hints show open work; an empty answer is confirmed by a full read before presence is released) may use the hints. `MissionControl.hintedMissions` is that read: it takes candidate IDs from the hints, re-reads each range with `missionView`, keeps snapshot order (stable by `updatedAt`, ties in hint order) and stamps the hints' project damage flags on each map as a snapshot does; isolated passage journals use their snapshot instead. Its callers: the context hook (`contextFor`, one membership read per model turn returning the instruction and whether the session coordinates the mission; a conversation no mission names reads no range), `mission.inspect` without `start` (by `missionID` reads exactly that range), `inspect({ list })`, the task observation and decision-candidate reads behind `inspect({ taskKey })`, and the notification outbox's enumeration over `openIDs()` (every running mission is open). Each notification then re-reads the whole journal before its native admission and before its receipt, and both the native message ID and the receipt ID are deterministic, so a mission the hints miss is only delayed until the rebuild and a stale candidate is skipped once the full read shows it admitted. The context of a mission written by such an unseen writer is withheld until the rebuild; its tools stay offered and every one re-selects authoritatively. `retainMissionWork` retains on `hintsShowWork` (an open mission or project damage) and otherwise decides on the full snapshot. Creation and `inspect({ start })`, declaration, delegation, revision, briefing, report, update, lifecycle, recovery, prepare, task interruption, deletion and cleanup keep their full reads; the change events after lifecycle and deletion carry the revision those operations already read. `journal-read-budget.test.ts` pins both sides over a finished history of 10,000 entries in 200 missions: the hinted paths make no full scan and read at most their own ranges, while membership refusals and damage gates see entries the hints have not. Isolated recurrence-passage storage (`MissionStorage.isolated`) writes its own physical namespace, never counts and has no index (`native-passage-business.test.ts` traps ordinary journal writes from passages).
### Explicit execution controls
@ -286,7 +286,7 @@ Display activity and recovery keep the 32-descendant bounded read. When it fails
Mission snapshots expose the latest 50 plan changes (`history`, with `historyTruncated` when older entries are omitted), including human objective/notes edits and coordinator revisions. Each entry has a revision and timestamp; `source` distinguishes `user` from `coordinator`. Human edits carry no actor session or reason; coordinator entries retain their actor and required reason. Entries without `source` are coordinator-originated. Snapshots also expose task lineage (`replacesTaskKey`/`replacedByTaskKey`), late reports, and `outstandingExecution`. A withdrawn task with admitted native work must receive a terminal report before the mission can be finished.
Build the shipped plugin with `npm run build:missions --workspace @neuralnomads/codenomad`, then run `node scripts/test-missions-native.mjs <absolute-isolated-opencode-cli>`: through the real bundled plugin, typed RPC, WorkspaceManager and bridge with a local deterministic provider, it exercises native tool registration/restart plus revision, late-report delivery, lifecycle create/update/delete replay, tombstone projection, settled deletion purge through native `storage.remove` (record-backed replay and recreation refusal), transcript preservation, catalog selection, variant persistence, a busy actor's durable queue, mismatched selection refusal, per-send actor/coordinator environment, presence removal/re-registration and retry idempotence, and a final finished-history stage: 100 finished missions, a reload whose new index builds over them, creation, declaration, readout and deletion beside them, then an entry written directly into the isolated runtime's KV table that full reads (snapshot, the declaration's damage gate) see at once while inspect and the context hook, reading through the index, do not. Report checks observe the actual provider request containing the report after resuming both an idle and a busy coordinator. It provisions its own home/config/database/provider and must never be pointed at the shared daemon.
Build the shipped plugin with `npm run build:missions --workspace @neuralnomads/codenomad`, then run `node scripts/test-missions-native.mjs <absolute-isolated-opencode-cli>`: through the real bundled plugin, typed RPC, WorkspaceManager and bridge with a local deterministic provider, it exercises native tool registration/restart plus revision, late-report delivery, lifecycle create/update/delete replay, tombstone projection, settled deletion purge through native `storage.remove` (record-backed replay and recreation refusal), transcript preservation, catalog selection, variant persistence, a busy actor's durable queue, mismatched selection refusal, per-send actor/coordinator environment, presence removal/re-registration and retry idempotence, and a final finished-history stage: 100 finished missions whose ranges are then seeded in the isolated KV table past 2,036 project entries, a reload whose new index builds over them, creation, declaration, readout and a deletion whose retention purge clears the oldest settled deletion beside them, then an entry written directly into the isolated runtime's KV table that full reads (snapshot, the declaration's damage gate) see at once while inspect and the context hook, reading through the index, do not. Report checks observe the actual provider request containing the report after resuming both an idle and a busy coordinator. It provisions its own home/config/database/provider and must never be pointed at the shared daemon.
The notification recovery regression removes the private bridge before a report, proves the report was durably saved despite admission failure, restores the bridge with and without a plugin restart, and requires a provider turn containing that report without another report call or human prompt. It also checks that an explicit replay after acknowledgement retains a single correlated native message and causes no further wake-up.
@ -368,10 +368,19 @@ This transport reuses only the automation bridge discovery; automation keeps its
descendant observation/reporting uses its separate ancestry/provenance contract.
Foreign projects are rejected; a graph edge never grants session authority.
- At most 8 actors and 96 tasks per mission, and 2,000 stored journal entries per
project; the number of missions is unbounded. Purge-capable storage adds a 36-entry
deletion reserve (`MISSION_DELETION_RESERVE`) that only tombstones and reserved
receipts may spend, so a full project can still delete; purging settled deletions
then frees the space. Reads stay bounded at 2,036 entries. Activity observation reads at most 160 actors per
mission (`MISSION_MAX_EVENTS`, counted over its key range, unreadable entries
included); the number of missions, and so a project's journal, is unbounded. Each
mission adds a 36-entry deletion reserve (`MISSION_DELETION_RESERVE`) that only its
deletion tombstone and its already reserved receipts may spend, on every storage, so
a full mission can still be deleted and finish its cleanup; a new Stop needs ordinary
room, and the receipts of a published Stop or deletion are reserved within the
mission's count. Capacity checks (`assertCanAppend`, `append`) read only the target
mission's range. A write's safety decisions still read the full journal
(§Journal index), so write cost grows with the project's history. Every read stops
past 2,036 entries in one mission range (entries outside every range share one such
bound); a full project read (`events()`/`snapshot()`, the index build) holds every
mission's events, bounded only by that per-mission bound times the number of
missions. That is the accepted cost of an unbounded project. Activity observation reads at most 160 actors per
refresh, active missions first, then the most recently updated; the rest read as
unknown.
- Existing root actors may be reused, but an actor cannot join two active missions.

View file

@ -156,6 +156,6 @@ Verwenden Sie **Ausgewählte Mission bearbeiten** für eine bearbeitbare einmali
2. Wählen Sie optional vor dem Bestätigen **Auch die für diese Mission erstellten Fachunterhaltungen löschen**.
3. Prüfen Sie **Gespräche bereinigen** auf entfernt/behalten/offen-Zahlen. Wiederholen Sie gegebenenfalls nur die verbleibende erfasste Bereinigung.
Eine Mission zu löschen ist nicht dasselbe wie sie zu stoppen; verwenden Sie zuerst Stop und prüfen Sie verbleibende Arbeit. Koordinatoren und wiederverwendete Unterhaltungen bleiben erhalten. Auch geteilte, verschobene oder nicht nachweislich zugehörige Spezialisten-Unterhaltungen sowie solche mit Untersitzungen können erhalten bleiben. Teilbereinigung wird nicht zurückgerollt und nicht stillschweigend im Hintergrund abgeschlossen. Die Übersicht behält die 20 zuletzt abgeschlossenen Bereinigungen. Gelöschte Missionen geben ihren Speicher im Projekt frei: Ältere gelöschte Missionen werden automatisch entfernt, die jüngsten ebenfalls, wenn das Projekt Platz für neue Missionen braucht.
Eine Mission zu löschen ist nicht dasselbe wie sie zu stoppen; verwenden Sie zuerst Stop und prüfen Sie verbleibende Arbeit. Koordinatoren und wiederverwendete Unterhaltungen bleiben erhalten. Auch geteilte, verschobene oder nicht nachweislich zugehörige Spezialisten-Unterhaltungen sowie solche mit Untersitzungen können erhalten bleiben. Teilbereinigung wird nicht zurückgerollt und nicht stillschweigend im Hintergrund abgeschlossen. Die Übersicht behält die 20 zuletzt abgeschlossenen Bereinigungen. Gelöschte Missionen geben ihren Speicher im Projekt frei: Ältere gelöschte Missionen werden automatisch entfernt.
Siehe [Berechtigungen](permissions.md), [Unterhaltungen](conversations.md) und [OpenCode-Einrichtung](opencode.md) für native Arbeit und Dienststeuerung.

View file

@ -156,6 +156,6 @@ Use **Edit selected mission** on an editable one-time mission; on a conflict, cl
2. Optionally select **Also delete specialist conversations created for this mission** before confirming.
3. Check **Conversation cleanup** for removed/kept/pending counts. Retry only the remaining recorded cleanup if offered.
Deleting a mission is not the same as stopping it; use Stop and inspect remaining work first. Coordinators and reused conversations are kept. Shared, moved, child-bearing or unverifiably owned specialists may also be retained. Partial cleanup is not rolled back and is not silently completed in the background. The overview reader keeps the 20 most recent completed cleanups. Deleting missions frees their project storage: older deleted missions are cleared automatically, and the recent ones too when the project needs room for new missions.
Deleting a mission is not the same as stopping it; use Stop and inspect remaining work first. Coordinators and reused conversations are kept. Shared, moved, child-bearing or unverifiably owned specialists may also be retained. Partial cleanup is not rolled back and is not silently completed in the background. The overview reader keeps the 20 most recent completed cleanups. Deleting missions frees their project storage: older deleted missions are cleared automatically.
See [Permissions](permissions.md), [Conversations](conversations.md) and [OpenCode setup](opencode.md) for native work and service controls.

View file

@ -156,6 +156,6 @@ Usa **Editar la misión seleccionada** en una misión puntual editable; ante un
2. Opcionalmente, selecciona **Eliminar también las conversaciones de especialistas creadas para esta misión** antes de confirmar.
3. Comprueba **Limpieza de conversaciones** para ver los recuentos de eliminadas/conservadas/pendientes. Reintenta solo la limpieza restante registrada si se ofrece.
Eliminar una misión no equivale a detenerla; usa Detener e inspecciona primero el trabajo restante. Se conservan los coordinadores y las conversaciones reutilizadas. También pueden conservarse especialistas compartidos, movidos, con hijos o cuya propiedad no se pueda verificar. La limpieza parcial no se revierte ni se completa silenciosamente en segundo plano. El lector general conserva las 20 limpiezas completadas más recientes. Eliminar misiones libera su almacenamiento en el proyecto: las misiones eliminadas más antiguas se borran automáticamente, y las recientes también cuando el proyecto necesita espacio para misiones nuevas.
Eliminar una misión no equivale a detenerla; usa Detener e inspecciona primero el trabajo restante. Se conservan los coordinadores y las conversaciones reutilizadas. También pueden conservarse especialistas compartidos, movidos, con hijos o cuya propiedad no se pueda verificar. La limpieza parcial no se revierte ni se completa silenciosamente en segundo plano. El lector general conserva las 20 limpiezas completadas más recientes. Eliminar misiones libera su almacenamiento en el proyecto: las misiones eliminadas más antiguas se borran automáticamente.
Consulta [Permisos](permissions.md), [Conversaciones](conversations.md) y [configuración de OpenCode](opencode.md) para el trabajo nativo y los controles del servicio.

View file

@ -156,6 +156,6 @@ Utilisez **Modifier la mission sélectionnée** sur une mission ponctuelle modif
2. Sélectionnez éventuellement **Supprimer aussi les conversations spécialistes créées pour cette mission** avant de confirmer.
3. Vérifiez dans **Nettoyage des conversations** les nombres supprimés/conservés/en attente. Réessayez seulement le nettoyage restant enregistré si proposé.
Supprimer une mission n’est pas l’arrêter ; utilisez d’abord l’arrêt et inspectez le travail restant. Les coordinateurs et conversations réutilisées sont conservés. Les spécialistes partagés, déplacés, ayant des enfants ou dont la propriété n’est pas vérifiable peuvent aussi être conservés. Un nettoyage partiel n’est ni annulé ni terminé silencieusement en arrière-plan. Le lecteur d’ensemble conserve les 20 nettoyages terminés les plus récents. Supprimer des missions libère leur stockage dans le projet : les missions supprimées plus anciennes sont effacées automatiquement, et les récentes aussi lorsque le projet a besoin de place pour de nouvelles missions.
Supprimer une mission n’est pas l’arrêter ; utilisez d’abord l’arrêt et inspectez le travail restant. Les coordinateurs et conversations réutilisées sont conservés. Les spécialistes partagés, déplacés, ayant des enfants ou dont la propriété n’est pas vérifiable peuvent aussi être conservés. Un nettoyage partiel n’est ni annulé ni terminé silencieusement en arrière-plan. Le lecteur d’ensemble conserve les 20 nettoyages terminés les plus récents. Supprimer des missions libère leur stockage dans le projet : les missions supprimées plus anciennes sont effacées automatiquement.
Voir [Autorisations](permissions.md), [Conversations](conversations.md) et [Configuration d’OpenCode](opencode.md) pour le travail natif et les commandes du service.

View file

@ -156,6 +156,6 @@
2. אפשר לסמן **למחוק גם שיחות מומחים שנוצרו עבור משימה זו** לפני האישור.
3. בדקו **ניקוי שיחות** למספרים שהוסרו/נשמרו/ממתינים. נסו שוב רק את הניקוי הרשום שנותר, אם מוצע.
מחיקת משימה אינה זהה לעצירתה; השתמשו בעצירה ובדקו עבודה שנותרה תחילה. מתאמים ושיחות בשימוש חוזר נשמרים. גם מומחים משותפים, מועברים, בעלי ילדים או שלא ניתן לאמת בעלות עליהם עשויים להישמר. ניקוי חלקי אינו מוחזר לאחור ואינו מושלם בשקט ברקע. קורא הסקירה שומר את 20 הניקויים האחרונים שהושלמו. מחיקת משימות מפנה את האחסון שלהן בפרויקט: משימות מחוקות ישנות יותר מנוקות אוטומטית, וגם האחרונות כשהפרויקט זקוק למקום למשימות חדשות.
מחיקת משימה אינה זהה לעצירתה; השתמשו בעצירה ובדקו עבודה שנותרה תחילה. מתאמים ושיחות בשימוש חוזר נשמרים. גם מומחים משותפים, מועברים, בעלי ילדים או שלא ניתן לאמת בעלות עליהם עשויים להישמר. ניקוי חלקי אינו מוחזר לאחור ואינו מושלם בשקט ברקע. קורא הסקירה שומר את 20 הניקויים האחרונים שהושלמו. מחיקת משימות מפנה את האחסון שלהן בפרויקט: משימות מחוקות ישנות יותר מנוקות אוטומטית.
ראו [אישורים](permissions.md), [שיחות](conversations.md) ו־[הגדרת OpenCode](opencode.md) לעבודה מקורית ולפקדי שירות.

View file

@ -156,6 +156,6 @@
2. 必要に応じて、確認前に **このミッション用に作成された専門担当の会話も削除する** を選びます。
3. **会話のクリーンアップ** で削除/保持/保留の件数を確認します。再試行が提示される場合は、記録された残りのクリーンアップだけを再試行します。
ミッションの削除と停止は同じではありません。先に停止を使用して残った作業を確認してください。コーディネーターと再利用された会話は保持されます。共有、移動済み、子を持つ、所有関係を確認できない専門担当も保持される場合があります。部分的なクリーンアップは巻き戻されず、バックグラウンドで無断で完了されることもありません。概要リーダーには、完了したクリーンアップのうち最新の 20 件が残ります。ミッションを削除すると、プロジェクト内のその保存領域が解放されます。古い削除済みミッションは自動的に消去され、新しいミッションのための空きが必要なときは最近のものも消去されます。
ミッションの削除と停止は同じではありません。先に停止を使用して残った作業を確認してください。コーディネーターと再利用された会話は保持されます。共有、移動済み、子を持つ、所有関係を確認できない専門担当も保持される場合があります。部分的なクリーンアップは巻き戻されず、バックグラウンドで無断で完了されることもありません。概要リーダーには、完了したクリーンアップのうち最新の 20 件が残ります。ミッションを削除すると、プロジェクト内のその保存領域が解放されます。古い削除済みミッションは自動的に消去されます。
ネイティブの作業とサービス制御については、[権限](permissions.md)、[会話](conversations.md)、[OpenCode のセットアップ](opencode.md)を参照してください。

View file

@ -156,6 +156,6 @@ report बिना idle one-time coordinator मा primary ▶ ले verify
2. चाहनुहुन्छ भने पुष्टि अघि **यस मिसनका लागि सिर्जना गरिएका विशेषज्ञ कुराकानीहरू पनि मेटाउनुहोस्** चयन गर्नुहोस्।
3. removed/kept/pending count का लागि **कुराकानी सफाइ** जाँच्नुहोस्। उपलब्ध भए बाँकी recorded cleanup मात्र retry गर्नुहोस्।
मिसन मेट्नु रोक्नुजस्तो होइन; पहिले Stop गरी बाँकी काम जाँच्नुहोस्। coordinator र reused कुराकानी राखिन्छन्। shared, moved, child भएका वा ownership प्रमाणित नसकिने specialist पनि राखिन सक्छन्। partial cleanup rollback हुँदैन र चुपचाप background मा पूरा गरिँदैन। overview reader ले पछिल्ला २० पूरा cleanup राख्छ। मिसन मेट्दा project मा तिनको storage खाली हुन्छ: पुराना मेटिएका मिसन आफैँ हटाइन्छन्, र project लाई नयाँ मिसनका लागि ठाउँ चाहिँदा हालका पनि हटाइन्छन्।
मिसन मेट्नु रोक्नुजस्तो होइन; पहिले Stop गरी बाँकी काम जाँच्नुहोस्। coordinator र reused कुराकानी राखिन्छन्। shared, moved, child भएका वा ownership प्रमाणित नसकिने specialist पनि राखिन सक्छन्। partial cleanup rollback हुँदैन र चुपचाप background मा पूरा गरिँदैन। overview reader ले पछिल्ला २० पूरा cleanup राख्छ। मिसन मेट्दा project मा तिनको storage खाली हुन्छ: पुराना मेटिएका मिसन आफैँ हटाइन्छन्।
native काम र service नियन्त्रणका लागि [अनुमति](permissions.md), [कुराकानी](conversations.md) र [OpenCode सेटअप](opencode.md) हेर्नुहोस्।

View file

@ -156,6 +156,6 @@
2. При желании выберите **Также удалить разговоры специалистов, созданные для этой миссии** перед подтверждением.
3. Проверьте **Очистка разговоров** для количества удалённых/сохранённых/ожидающих. Повторяйте только оставшуюся записанную очистку, если предложено.
Удаление миссии не равно остановке; сначала остановите и проверьте оставшуюся работу. Координаторы и повторно используемые разговоры сохраняются. Общие, перемещённые, имеющие детей специалисты или специалисты с непроверяемой принадлежностью тоже могут сохраняться. Частичная очистка не откатывается и не завершается незаметно в фоне. Просмотрщик обзора хранит 20 последних завершённых очисток. Удаление миссий освобождает их хранилище в проекте: более старые удалённые миссии стираются автоматически, а недавние — тоже, когда проекту нужно место для новых миссий.
Удаление миссии не равно остановке; сначала остановите и проверьте оставшуюся работу. Координаторы и повторно используемые разговоры сохраняются. Общие, перемещённые, имеющие детей специалисты или специалисты с непроверяемой принадлежностью тоже могут сохраняться. Частичная очистка не откатывается и не завершается незаметно в фоне. Просмотрщик обзора хранит 20 последних завершённых очисток. Удаление миссий освобождает их хранилище в проекте: более старые удалённые миссии стираются автоматически.
Нативная работа и управление службой описаны в разделах [Разрешения](permissions.md), [Разговоры](conversations.md) и [Настройка OpenCode](opencode.md).

View file

@ -156,6 +156,6 @@ Düzenlenebilir tek seferlik görevde **Seçili görevi düzenle** eylemini kull
2. Onaylamadan önce isteğe bağlı olarak **Bu görev için oluşturulan uzman konuşmalarını da sil** seçeneğini işaretleyin.
3. Kaldırılan/korunan/bekleyen sayılarını **Konuşma temizliği** bölümünde kontrol edin. Sunuluyorsa yalnızca kaydedilmiş, kalan temizliği yeniden deneyin.
Görevi silmek durdurmakla aynı şey değildir; önce Durdur'u kullanıp kalan işleri inceleyin. Koordinatörler ve yeniden kullanılan konuşmalar korunur. Paylaşılan, taşınan, alt konuşması bulunan veya sahipliği doğrulanamayan uzmanlar da korunabilir. Kısmi temizlik geri alınmaz ve arka planda sessizce tamamlanmaz. Genel bakış okuyucusu tamamlanan en son 20 temizliği tutar. Görevleri silmek projedeki depolama alanlarını boşaltır: daha eski silinmiş görevler otomatik olarak temizlenir, proje yeni görevler için yer gerektirdiğinde yakın tarihli olanlar da temizlenir.
Görevi silmek durdurmakla aynı şey değildir; önce Durdur'u kullanıp kalan işleri inceleyin. Koordinatörler ve yeniden kullanılan konuşmalar korunur. Paylaşılan, taşınan, alt konuşması bulunan veya sahipliği doğrulanamayan uzmanlar da korunabilir. Kısmi temizlik geri alınmaz ve arka planda sessizce tamamlanmaz. Genel bakış okuyucusu tamamlanan en son 20 temizliği tutar. Görevleri silmek projedeki depolama alanlarını boşaltır: daha eski silinmiş görevler otomatik olarak temizlenir.
OpenCode işleri ve hizmet kontrolleri için [İzinler](permissions.md), [Konuşmalar](conversations.md) ve [OpenCode kurulumu](opencode.md) sayfalarına bakın.

View file

@ -156,6 +156,6 @@
2. 确认前可选中**同时删除为此任务创建的专家会话**。
3. 查看**会话清理**中的已移除/保留/待处理数量。提供重试时,只重试剩余的已记录清理。
删除任务不同于停止;请先停止并检查剩余工作。协调者和复用对话会保留。共享、移动、有子会话或无法验证归属的专家也可能保留。部分清理不会回滚,也不会在后台静默完成。概览阅读器保留最近 20 次已完成的清理。删除任务会释放其在项目中的存储:较早删除的任务会自动清除,项目需要空间创建新任务时,最近删除的任务也会被清除。
删除任务不同于停止;请先停止并检查剩余工作。协调者和复用对话会保留。共享、移动、有子会话或无法验证归属的专家也可能保留。部分清理不会回滚,也不会在后台静默完成。概览阅读器保留最近 20 次已完成的清理。删除任务会释放其在项目中的存储:较早删除的任务会自动清除。
原生工作和服务控件请参阅[权限](permissions.md)、[对话](conversations.md)和 [OpenCode 设置](opencode.md)。

View file

@ -1,8 +1,10 @@
import { createHash } from "node:crypto"
import { MISSION_MAX_EVENTS, type MissionCleanup, type MissionCleanupReason, type MissionDeletedEvent, type MissionEvent } from "./model"
import type { MissionCleanup, MissionCleanupReason, MissionDeletedEvent, MissionEvent } from "./model"
/** Settled cleanups shown after the pending ones. */
export const SETTLED_CLEANUP_HISTORY = 20
/** Payload bound of the displayed cleanups. */
export const MAX_PROJECTED_CLEANUPS = 2_000
/** The session a mission event makes that mission's member for shared-session cleanup checks. */
export function cleanupReference(event: MissionEvent): string | undefined {
@ -80,10 +82,9 @@ export function projectMissionCleanups(events: readonly MissionEvent[]): Mission
/** Newest first: every pending cleanup, then the `settled` newest settled ones. */
export function orderMissionCleanups(results: MissionCleanup[], settled = SETTLED_CLEANUP_HISTORY): MissionCleanup[] {
results.sort((a, b) => b.createdAt - a.createdAt || a.deletionID.localeCompare(b.deletionID))
// Keep EVERY pending intent within the journal's existing hard bound, even if
// newer completed deletions would otherwise push it out of the display window.
// Pending intents come first, so newer completed deletions never push one out of the display window.
return [...results.filter(item => item.pending > 0), ...results.filter(item => item.pending === 0).slice(0, settled)]
.slice(0, MISSION_MAX_EVENTS)
.slice(0, MAX_PROJECTED_CLEANUPS)
}
function projectDeletion(deletion: MissionDeletedEvent, group: readonly MissionEvent[], objective: string): MissionCleanup {

View file

@ -794,11 +794,12 @@ test("bounds and validates persisted journal records", async () => {
assert.equal(snapshot.missions.length, 0)
})
test("rejects a new event before the durable journal can exceed its safety limit", async () => {
test("rejects a new event before its mission's durable range can exceed its safety limit", async () => {
const storage = new MemoryStorage()
const journal = new MissionJournal(storage, "project-1", "/repo", () => 100)
// Unreadable entries still occupy their mission's range.
for (let index = 0; index < MISSION_MAX_EVENTS; index += 1) {
storage.values.set(`${MISSION_JOURNAL_STORAGE_PREFIX}/${journal.projectToken}/seed/${index.toString().padStart(4, "0")}`, { seed: index })
storage.values.set(`${MISSION_JOURNAL_STORAGE_PREFIX}/${journal.projectToken}/msn_capacity/${index.toString().padStart(4, "0")}`, { seed: index })
}
await assert.rejects(journal.append({

View file

@ -289,9 +289,6 @@ export class MissionControl {
}
// Read after the creation event: a purge records the deletion before removing it.
await this.assertNotPurged(missionID)
// The exact creation event is absent in a healthy journal, and neither a
// coordinator creation nor publication has been attempted by this invocation.
await this.journal.assertCanAppend()
try { await this.options.validateProfiles?.(input.profiles, input.expectedCoordinatorLocation?.directory ?? this.options.project.location.directory, input.taskMode) }
catch { throw new MissionControlError("Mission profiles do not match the owned native catalog", "invalid-execution") }

View file

@ -23,9 +23,11 @@ function fixture() {
const base = { version: 1 as const, projectID: "project", missionID: "msn_owned", createdAt: 1 }
const eventKey = (event: MissionEvent) => `${prefix}/${event.missionID}/${event.id}`
const seed = (event: MissionEvent) => { assert(parseMissionEvent(event)); values.set(eventKey(event), structuredClone(event) as unknown as MissionJsonValue) }
const ordinary = (id = "evt_extra"): MissionEvent => ({ ...base, id, type: "mission.updated", requestID: id,
const ordinary = (id = "evt_extra", missionID = base.missionID): MissionEvent => ({ ...base, missionID, id, type: "mission.updated", requestID: id,
expectedRevision: 1, notesSpecified: false, objective: "unrelated update" })
const fillTo = (size: number) => { for (let i = values.size; values.size < size; i++) seed(ordinary(`evt_filler_${i}`)) }
// Capacity is per mission: only the entries of that mission's range count.
const own = (missionID = base.missionID) => [...values.keys()].filter(key => key.startsWith(`${prefix}/${missionID}/`)).length
const fillTo = (size: number, missionID = base.missionID) => { for (let i = own(missionID); own(missionID) < size; i++) seed(ordinary(`evt_filler_${i}`, missionID)) }
const deletion = (missionID = base.missionID, id = "evt_deleted", targets = ["ses_owned"]): Extract<MissionEvent, { type: "mission.deleted" }> => ({
...base, missionID, id, type: "mission.deleted", requestID: "delete-request", expectedRevision: 1, deleteManagedSessions: true,
cleanupTargets: targets.map(sessionID => ({ sessionID, location: { directory: "/owned/project" } })),
@ -42,7 +44,7 @@ function fixture() {
...base, missionID: intent.missionID, id: `evt_${stableToken(`${intent.id}\0applied\0${sessionID}`, 28)}`,
type: "mission.control-applied", operationID: intent.id, sessionID,
})
return { journal, values, prefix, base, eventKey, seed, ordinary, fillTo, deletion, receipt, control, applied,
return { journal, values, prefix, base, eventKey, seed, ordinary, own, fillTo, deletion, receipt, control, applied,
counts: () => ({ writes, scans }) }
}
@ -69,22 +71,27 @@ for (const damage of damageCases) {
else if (damage === "malformed") event = { ...genuine, id: "evt_bad_receipt", outcome: "unknown" }
if (["deletion", "session", "wrong-kind"].includes(damage)) key = f.eventKey(event as MissionEvent)
f.values.set(key, structuredClone(event) as unknown as MissionJsonValue)
if (damage === "foreign-orphan") {
// Another mission's range costs this mission nothing; fill its place.
await f.journal.assertCanAppend(f.base.missionID)
f.seed(f.ordinary("evt_filler_extra"))
}
const foreignBytes = JSON.stringify(f.values.get(key)), writes = f.counts().writes
assert.equal(f.values.size, 1999)
assert.equal(f.own(), 1999)
const observation = await f.journal.events()
if (["original-foreign-miskeyed", "project", "mission", "key", "malformed"].includes(damage)) {
assert.equal(observation.discardedEvents, 1)
const segments = key.split("/")
await assert.rejects(f.journal.event(segments.at(-2)!, segments.at(-1)!), /identity mismatch/)
}
await assert.rejects(f.journal.assertCanAppend(), /2000-event safety limit/)
await assert.rejects(f.journal.assertCanAppend(f.base.missionID), /2000-event safety limit/)
await assert.rejects(f.journal.append(f.ordinary()), /2000-event safety limit/)
assert.equal(f.counts().writes, writes)
assert.equal(f.values.size, 1999)
assert.equal(f.own(), 1999)
// Low-level capacity does not authorize actual cleanup through a damaged
// observation. Existing control/bridge regressions independently deny it.
await f.journal.append(genuine)
assert.equal(f.values.size, 2000); assert.equal(f.counts().writes, writes + 1)
assert.equal(f.own(), 2000); assert.equal(f.counts().writes, writes + 1)
assert.equal(JSON.stringify(f.values.get(key)), foreignBytes)
await f.journal.append(genuine) // exact durable append remains idempotent
await assert.rejects(f.journal.append(f.ordinary()), /2000-event safety limit/)
@ -106,10 +113,12 @@ for (const damage of ["project", "mission", "key", "alternate-event-id", "foreig
else if (damage === "session") { event = f.applied(intent, "ses_other"); key = f.eventKey(event) }
else if (damage === "wrong-kind") { event = f.receipt(f.deletion(f.base.missionID, intent.id)); key = f.eventKey(event) }
f.values.set(key, structuredClone(event) as unknown as MissionJsonValue)
// Another mission's range costs this mission nothing; fill its place.
if (damage === "foreign-orphan") f.seed(f.ordinary("evt_filler_extra"))
const badBytes = JSON.stringify(f.values.get(key))
await assert.rejects(f.journal.append(f.ordinary()), /2000-event safety limit/)
await f.journal.append(genuine)
assert.equal(f.values.size, 2000); assert.equal(JSON.stringify(f.values.get(key)), badBytes)
assert.equal(f.own(), 2000); assert.equal(JSON.stringify(f.values.get(key)), badBytes)
})
}
@ -119,14 +128,14 @@ test("invalid physical intent cannot establish reservation or supersede the late
const invalid = kind === "cleanup" ? f.deletion() : f.control()
f.values.set(`${f.prefix}/msn_foreign/${invalid.id}`, invalid as unknown as MissionJsonValue)
assert.equal((await f.journal.events()).discardedEvents, 1)
await f.journal.append(f.ordinary()); assert.equal(f.values.size, 2000)
await f.journal.append(f.ordinary()); await f.journal.append(f.ordinary("evt_last")); assert.equal(f.own(), 2000)
}
const f = fixture(); f.fillTo(1997)
const f = fixture(); f.fillTo(1998)
const valid = f.control(); await f.journal.append(valid)
const invalid = { ...f.control(f.base.missionID, "evt_corrupt_control", ["ses_other"]), createdAt: 2 }
f.values.set(`${f.prefix}/msn_wrong/${invalid.id}`, invalid as unknown as MissionJsonValue)
await assert.rejects(f.journal.append(f.ordinary()), /2000-event safety limit/)
await f.journal.append(f.applied(valid)); assert.equal(f.values.size, 2000)
await f.journal.append(f.applied(valid)); assert.equal(f.own(), 2000)
})
test("occupied deterministic receipt key remains a collision, never a repair or permission to use reserved capacity", async () => {
@ -158,14 +167,18 @@ test("reservations correlate mission/type/original target despite repeated inten
assert.equal(snapshot.missions.length, 38, "13 undeleted historical missions and 25 newer ones")
assert.equal(snapshot.discardedEvents, 0)
assert.equal(snapshot.cleanupUnavailable, undefined)
f.fillTo(2000 - intents.length)
for (const intent of intents) {
await assert.rejects(f.journal.append(f.ordinary()), /safety limit/)
// One full mission: every other mission's receipt for the same intent ID discharges only its own slot.
const [full, ...others] = intents
f.fillTo(2000 - 1, full.missionID)
for (const intent of others) {
await assert.rejects(f.journal.append(f.ordinary("evt_extra", full.missionID)), /safety limit/)
await f.journal.append(intent.type === "mission.deleted" ? f.receipt(intent) : f.applied(intent))
}
assert.equal(f.values.size, 2000)
assert.equal((await f.journal.events()).events.length, 2000)
await assert.rejects(f.journal.append(f.ordinary()), /safety limit/)
await assert.rejects(f.journal.append(f.ordinary("evt_extra", full.missionID)), /safety limit/)
await f.journal.append(f.applied(full as ReturnType<typeof f.control>))
assert.equal(f.own(full.missionID), 2000)
assert.equal((await f.journal.events()).events.length, 2000 + 3 * others.length + 25, "a project holds more than one mission's bound")
await assert.rejects(f.journal.append(f.ordinary("evt_extra", full.missionID)), /safety limit/)
})
test("every bounded cleanup target retains its own slot up to the 2000-event physical limit", async () => {
@ -177,16 +190,17 @@ test("every bounded cleanup target retains its own slot up to the 2000-event phy
await assert.rejects(f.journal.append(f.ordinary()), /safety limit/)
await f.journal.append(f.receipt(intent, target))
}
assert.equal(f.values.size, 2000)
await f.journal.assertCanAppend(0)
assert.equal(f.own(), 2000)
await f.journal.assertCanAppend(f.base.missionID, 0)
await f.journal.append(f.receipt(intent, targets[0]))
await assert.rejects(f.journal.assertCanAppend(), /safety limit/)
await assert.rejects(f.journal.assertCanAppend(f.base.missionID), /safety limit/)
// Reads stay bounded by the physical limit, which includes the deletion reserve.
f.values.set(`${f.prefix}/msn_owned/evt_2001`, f.ordinary("evt_2001") as unknown as MissionJsonValue)
await assert.rejects(f.journal.assertCanAppend(), /reached the 2000-event safety limit/)
await assert.rejects(f.journal.assertCanAppend(f.base.missionID), /reached the 2000-event safety limit/)
f.fillTo(MISSION_MAX_STORED_EVENTS + 1)
await assert.rejects(f.journal.events(), new RegExp(`exceeds the ${MISSION_MAX_STORED_EVENTS}-event safety limit`))
await assert.rejects(f.journal.assertCanAppend(), new RegExp(`exceeds the ${MISSION_MAX_STORED_EVENTS}-event safety limit`))
for (const read of [() => f.journal.events(), () => f.journal.missionEvents(f.base.missionID), () => f.journal.assertCanAppend(f.base.missionID)]) {
await assert.rejects(read(), new RegExp(`exceeds the ${MISSION_MAX_STORED_EVENTS}-event safety limit`))
}
})
test("legitimate retained cleanup receipts discharge only their exact declared target", async () => {
@ -197,7 +211,7 @@ test("legitimate retained cleanup receipts discharge only their exact declared t
await assert.rejects(f.journal.append(f.ordinary()), /safety limit/)
await f.journal.append({ ...f.receipt(intent, "ses_two"), outcome: "retained", reason: "children" } as MissionEvent)
assert.equal(f.values.size, 2000)
await f.journal.assertCanAppend(0)
await f.journal.assertCanAppend(f.base.missionID, 0)
})
test("prospective Stop preflight and append replace only the superseded control reservation", async () => {
@ -211,20 +225,20 @@ test("prospective Stop preflight and append replace only the superseded control
await assert.rejects(f.journal.append(f.applied(pause)), /safety limit/, "an old receipt cannot consume the new Stop's slot")
await f.journal.append(f.applied(stop))
assert.equal(f.values.size, 2000)
await f.journal.assertCanAppend(0)
await f.journal.assertCanAppend(f.base.missionID, 0)
const writes = f.counts().writes
await f.journal.append(stop)
assert.equal(f.counts().writes, writes, "the original intent replay writes nothing")
})
test("prospective control credit cannot waive ordering, revision, scope or real receipt capacity", async () => {
for (const fault of ["project", "mission", "time", "tie-order", "revision", "nonterminal", "old-stop", "extra-target"] as const) {
// A Stop of another mission is that mission's own capacity question (see the per-mission tests).
for (const fault of ["project", "time", "tie-order", "revision", "nonterminal", "old-stop", "extra-target"] as const) {
const f = fixture(); f.fillTo(1997)
const previous = { ...f.control(), action: fault === "old-stop" ? "stop" as const : "pause" as const }
await f.journal.append(previous)
const stop = { ...f.control(f.base.missionID, "evt_stop"), createdAt: 2, expectedRevision: 2 }
if (fault === "project") stop.projectID = "foreign-project"
if (fault === "mission") stop.missionID = "msn_other"
if (fault === "time") stop.createdAt = 0
if (fault === "tie-order") { stop.createdAt = previous.createdAt; stop.id = "evt_aaa" }
if (fault === "revision") stop.expectedRevision = previous.expectedRevision
@ -238,8 +252,8 @@ test("prospective control credit cannot waive ordering, revision, scope or real
}
})
test("Stop leaves cleanup and other-mission control reservations intact", async () => {
const f = fixture(); f.fillTo(1994)
test("Stop leaves cleanup reservations intact; other missions' reservations stay in their own ranges", async () => {
const f = fixture(); f.fillTo(1996)
const pause = { ...f.control(), action: "pause" as const }
await f.journal.append(pause)
await f.journal.append(f.deletion())
@ -249,6 +263,32 @@ test("Stop leaves cleanup and other-mission control reservations intact", async
await assert.rejects(f.journal.assertCanAppend(stop), /safety limit/)
await assert.rejects(f.journal.append(stop), /safety limit/)
assert.deepEqual([...f.values], bytes)
await f.journal.assertCanAppend({ ...f.control("msn_other", "evt_other_stop"), createdAt: 2, expectedRevision: 2 })
})
test("a mission at its bound refuses ordinary entries but keeps its deletion reserve; other missions stay writable", async () => {
// This storage cannot remove entries: the reserve never depends on a purge.
const f = fixture(), targets = Array.from({ length: MISSION_MAX_ACTORS }, (_, i) => `ses_target_${i}`)
f.fillTo(MISSION_MAX_EVENTS)
await assert.rejects(f.journal.assertCanAppend(f.base.missionID), /reached the 2000-event safety limit of this mission/)
await assert.rejects(f.journal.append(f.ordinary()), /safety limit/)
await assert.rejects(f.journal.append(f.control()), /safety limit/, "a new Stop needs ordinary room")
// Other missions write well past the full mission's bound.
f.fillTo(MISSION_MAX_EVENTS - 1, "msn_neighbour")
await f.journal.append(f.ordinary("evt_neighbour", "msn_neighbour"))
assert.equal(f.own("msn_neighbour"), MISSION_MAX_EVENTS)
const intent = f.deletion(f.base.missionID, "evt_full_delete", targets)
await f.journal.assertCanAppend(intent)
await f.journal.append(intent)
for (const target of targets) {
await assert.rejects(f.journal.append(f.ordinary()), /safety limit/)
await f.journal.append(f.receipt(intent, target))
}
assert.equal(f.own(), MISSION_MAX_EVENTS + 1 + targets.length)
assert.ok(f.own() <= MISSION_MAX_STORED_EVENTS)
await assert.rejects(f.journal.append(f.ordinary()), /safety limit/)
const { events, discardedEvents } = await f.journal.events()
assert.deepEqual([events.length, discardedEvents], [f.values.size, 0])
})
test("append rechecks prospective Stop capacity after an earlier preflight", async () => {
@ -284,6 +324,6 @@ for (const [pauseID, stopID, newer] of [["evt_Z", "evt_a", false], ["evt_a", "ev
await f.journal.append(f.applied(stop))
assert.deepEqual((await f.journal.snapshot()).missions[0].control?.pending, [])
assert.equal(f.values.size, 2000)
await f.journal.assertCanAppend(0)
await f.journal.assertCanAppend(f.base.missionID, 0)
})
}

View file

@ -113,12 +113,13 @@ test("cross-mission edges compose: shared sessions, one range's orphaned receipt
assert.deepEqual(snapshot.missions.map(mission => mission.id), [...updatedTie, ...tied, second.id, first.id])
})
test("a mission range keeps the journal's read bound", async () => {
test("a mission range keeps its read bound in range and project reads", async () => {
const f = fixture(1)
const { mission } = await f.control().create({ requestID: "large", objective: "Large", template: "custom" })
for (let index = 0; index <= MISSION_MAX_STORED_EVENTS; index++) f.values.set(`${f.prefix}/${mission.id}/evt_filler_${index}`, { damaged: true })
await assert.rejects(f.journal.missionView(mission.id), new RegExp(`${MISSION_MAX_STORED_EVENTS}-event safety limit`))
await assert.rejects(f.journal.snapshot(), new RegExp(`${MISSION_MAX_STORED_EVENTS}-event safety limit`))
await assert.rejects(f.journal.readHints(hints => hints.openIDs()), new RegExp(`${MISSION_MAX_STORED_EVENTS}-event safety limit`))
})
test("a mission range read never straddles a purge", async () => {

View file

@ -78,7 +78,7 @@ function fixture(options: { removable?: boolean } = {}) {
// Raw durable entries, as an earlier build or a long history left them.
const seed = (event: MissionEvent) => values.set(`${prefix}/${event.missionID}/${event.id}`, structuredClone(event) as unknown as MissionJsonValue)
const fill = (missionID: string) => {
for (let index = 0; entries().length < MISSION_MAX_EVENTS; index++) seed({ version: 1, id: `evt_filler_${index}`, type: "mission.updated",
for (let index = 0; entries(missionID).length < MISSION_MAX_EVENTS; index++) seed({ version: 1, id: `evt_filler_${index}`, type: "mission.updated",
missionID, projectID: "project-1", requestID: `filler-${index}`, expectedRevision: 1, notesSpecified: false, objective: "Filler", createdAt: 2_000 + index })
}
return { values, storage, withoutRemoval, native, nativeRemoved, failing, removed, control, journal, prefix, create, delegate, deletion, entries, records, seed, fill,
@ -97,11 +97,11 @@ async function assertHealthy(f: ReturnType<typeof fixture>) {
assert.equal(snapshot.controlUnavailable, undefined)
assert.equal(snapshot.notificationUnavailable, undefined)
assert.ok((snapshot.cleanups ?? []).every(item => item.pending === 0), "a purge never reopens settled cleanup")
await f.journal.assertCanAppend(0)
for (const mission of snapshot.missions) await f.journal.assertCanAppend(mission.id, 0)
return snapshot
}
test("create/delete cycles free journal capacity instead of accumulating tombstones", async () => {
test("create/delete cycles free journal storage instead of accumulating tombstones", async () => {
const f = fixture()
for (let index = 0; index < 200; index++) {
const mission = await f.delegate(await f.create(`cycle-${index}`), "worker")
@ -112,7 +112,7 @@ test("create/delete cycles free journal capacity instead of accumulating tombsto
assert.deepEqual(snapshot.missions, [])
assert.equal(snapshot.cleanups?.length, 20, "the newest settled cleanups stay visible")
assert.equal(f.records().length, 180)
// Capacity pressure purges the retained history too.
// An explicit full purge clears the retained history too.
assert.equal(await f.journal.purgeDeleted({ all: true }) > 0, true)
assert.deepEqual(f.entries(), [])
assert.equal(f.records().length, 200)
@ -217,56 +217,39 @@ test("a late write to a purged mission is absorbed and removed by the next purge
assert.deepEqual(f.entries(mission.id), [])
})
test("tombstones written without purge are purged when capacity is needed", async () => {
const legacy = fixture({ removable: false })
for (let index = 0; index < 30; index++) await legacy.control().delete(legacy.deletion(await legacy.create(`legacy-${index}`)))
assert.equal(legacy.entries().length, 60, "no purge without native removal")
assert.equal(await legacy.journal.purgeDeleted({ all: true }), 0)
test("tombstones written without removal support are purged by the next deletion with it", async () => {
const kept = fixture({ removable: false })
for (let index = 0; index < 30; index++) await kept.control().delete(kept.deletion(await kept.create(`kept-${index}`)))
assert.equal(kept.entries().length, 60, "no purge without native removal")
assert.equal(await kept.journal.purgeDeleted({ all: true }), 0)
// The same storage with removal: an ordinary deletion purges beyond the recent history.
const f = fixture()
for (const [key, value] of legacy.values) f.values.set(key, value)
for (const [key, value] of kept.values) f.values.set(key, value)
await f.control().delete(f.deletion(await f.create("next")))
assert.equal(f.entries().length, 2 * 20)
// A journal full of legacy tombstones accepts a new mission by purging them.
const g = fixture()
for (let index = 0; g.entries().length < MISSION_MAX_EVENTS; index++) {
const base = { version: 1 as const, projectID: "project-1", missionID: `msn_legacy_${index}`, createdAt: 10 + index }
g.seed({ ...base, id: "evt_created", type: "mission.created", projectCanonical: "/repo", objective: "Legacy",
template: "custom", coordinator: { sessionID: `ses_legacy_${index}`, title: "Coordinator", location: { directory: "/repo" } } })
g.seed({ ...base, id: "evt_deleted", type: "mission.deleted", requestID: "delete", expectedRevision: 1 })
}
const stuck = fixture({ removable: false })
for (const [key, value] of g.values) stuck.values.set(key, value)
await assert.rejects(stuck.create("after-full"), /2000-event safety limit/, "the same journal stays full without native removal")
const created = await g.create("after-full")
assert.deepEqual(g.entries().filter(key => !key.includes(created.id)), [])
assert.equal(g.records().length, MISSION_MAX_EVENTS / 2)
await assertHealthy(g)
})
test("a journal full of live missions can still delete, then recovers capacity", async () => {
const f = fixture()
const crowded = await f.delegate(await f.create("crowded"), "worker")
const other = await f.create("other")
f.fill(crowded.id)
await assert.rejects(f.create("refused"), /2000-event safety limit/)
const current = (await f.control().snapshot()).missions.find(mission => mission.id === crowded.id)!
await f.control().delete(f.deletion(current, true))
assert.ok(f.entries().length > MISSION_MAX_EVENTS && f.entries().length <= MISSION_MAX_EVENTS + MISSION_DELETION_RESERVE)
assert.deepEqual(f.nativeRemoved, [specialist(crowded)])
const next = await f.create("after-recovery")
assert.deepEqual(f.entries(crowded.id), [])
assert.deepEqual((await assertHealthy(f)).missions.map(mission => mission.id).sort(), [other.id, next.id].sort())
})
test("without native removal a full journal keeps today's limit", async () => {
const f = fixture({ removable: false })
const crowded = await f.create("crowded")
f.fill(crowded.id)
const current = (await f.control().snapshot()).missions[0]
await assert.rejects(f.control().delete(f.deletion(current)), /2000-event safety limit/)
assert.equal(f.entries().length, MISSION_MAX_EVENTS)
})
for (const removable of [true, false]) {
test(`a full mission still deletes through its own reserve; the rest of the project stays writable (removal ${removable})`, async () => {
const f = fixture({ removable })
const crowded = await f.delegate(await f.create("crowded"), "worker")
const other = await f.create("other")
f.fill(crowded.id)
const current = (await f.control().snapshot()).missions.find(mission => mission.id === crowded.id)!
await assert.rejects(f.control().update({ missionID: crowded.id, requestID: "refused", expectedRevision: current.revision, objective: "Refused" }),
/2000-event safety limit of this mission/)
// No project cap: new missions and other missions' events still land.
const next = await f.create("beside-full")
await f.control().update({ missionID: other.id, requestID: "other-update", expectedRevision: other.revision, objective: "Other" })
await f.control().delete(f.deletion(current, true))
const own = f.entries(crowded.id).length
assert.ok(own > MISSION_MAX_EVENTS && own <= MISSION_MAX_EVENTS + MISSION_DELETION_RESERVE, `${own}`)
assert.deepEqual(f.nativeRemoved, [specialist(crowded)])
// The settled deletion stays as recent cleanup history: purging is retention, never room-making.
assert.equal(f.records().length, 0)
assert.deepEqual((await assertHealthy(f)).missions.map(mission => mission.id).sort(), [other.id, next.id].sort())
})
}
test("a deleted mission sharing a session another mission can still clean up keeps its membership evidence", async () => {
for (const membership of ["coordinator", "specialist"] as const) {

View file

@ -3,20 +3,20 @@ import test from "node:test"
import { fixture, type HistoryFixture } from "./journal-history-fixture"
import { MISSION_INDEX_STALE_AFTER_MS, MissionJournalIndex } from "./journal-index"
import type { MissionJsonValue } from "./model"
import { MISSION_MAX_STORED_EVENTS, type MissionJsonValue } from "./model"
import { setupMissionsPlugin } from "../opencode/missions-plugin"
import { hintsShowWork, retainMissionWork } from "../opencode/missions/lifetime"
// Finished history that reads of other missions must not pay for. The project
// event cap still bounds it (every full read refuses more), so it fills that cap.
const HISTORY_MISSIONS = 190, EVENTS_PER_MISSION = 10, HISTORY = HISTORY_MISSIONS * EVENTS_PER_MISSION
// Finished history that reads of other missions must not pay for: several
// missions' worth of entries, since the event bound is per mission.
const HISTORY_MISSIONS = 200, EVENTS_PER_MISSION = 50, HISTORY = HISTORY_MISSIONS * EVENTS_PER_MISSION
const location = { directory: "/repo" }
/** Bytes as an earlier activation left them: valid finished missions, read by this activation's first build. */
function seedHistory(f: HistoryFixture) {
for (let m = 0; m < HISTORY_MISSIONS; m++) {
const missionID = `msn_hist_${String(m).padStart(3, "0")}`
const base = (id: string, at: number) => ({ version: 1, id, missionID, projectID: "project-1", createdAt: 10 + m * 20 + at })
const base = (id: string, at: number) => ({ version: 1, id, missionID, projectID: "project-1", createdAt: 10 + m * 60 + at })
const events = [
{ ...base("evt_created", 0), type: "mission.created", projectCanonical: "/repo", objective: `History ${m}`, template: "custom",
coordinator: { sessionID: `ses_hist_${m}`, title: "Coordinator", location } },
@ -73,6 +73,32 @@ test("read-mostly paths read one mission's range, never the finished history", a
assert.deepEqual([retained.value, retained.fullScans, retained.entries], [true, 0, 0])
})
test("a project far beyond one mission's bound reads, creates, appends, deletes and purges", async () => {
const { f, mission } = await liveMission(8)
assert.ok(HISTORY > 4 * MISSION_MAX_STORED_EVENTS)
await f.control().declare(mission.coordinatorSessionId, { missionID: mission.id, taskKey: "more", title: "More", brief: "Bounded", role: "research", blockedBy: [] })
const snapshot = await f.journal.snapshot()
assert.deepEqual([snapshot.discardedEvents, snapshot.missions.length], [0, HISTORY_MISSIONS + 1])
assert.equal(snapshot.missions.find(candidate => candidate.id === mission.id)?.tasks.length, 2)
const finished = snapshot.missions.find(candidate => candidate.id === "msn_hist_000")!
await f.control().delete({ missionID: finished.id, requestID: "delete-history", expectedRevision: finished.revision })
assert.equal(await f.journal.purgeDeleted({ all: true }), EVENTS_PER_MISSION + 1)
const after = await f.journal.snapshot()
assert.deepEqual([after.discardedEvents, after.missions.length, after.missions.some(candidate => candidate.id === finished.id)], [0, HISTORY_MISSIONS, false])
assert.deepEqual(await f.journal.readHints(hints => hints.openIDs()), [mission.id])
})
test("capacity checks and appends read only their mission's range", async () => {
const { f, mission, own } = await liveMission(9)
const checked = await cost(f, () => f.journal.assertCanAppend(mission.id))
assert.deepEqual([checked.fullScans, checked.entries], [0, own])
const history = await cost(f, () => f.journal.assertCanAppend("msn_hist_007", 0))
assert.deepEqual([history.fullScans, history.entries], [0, EVENTS_PER_MISSION])
const appended = await cost(f, () => f.journal.append({ version: 1, id: "evt_budget", type: "task.dispatched", missionID: mission.id,
projectID: "project-1", taskKey: "work", createdAt: 90_000 }))
assert.deepEqual([appended.fullScans, appended.entries], [0, own])
})
test("membership refusals and damage gates read what the hints have not seen", async () => {
const { f, mission } = await liveMission(2)
f.native.set("ses_claimed", { id: "ses_claimed", projectID: "project-1", title: "Coordinator", location })

View file

@ -40,6 +40,8 @@ const MAX_TEXT = 20_000
const MAX_SHORT_TEXT = 240
// A key's mission range, relative to the project prefix; anything else lies outside every range.
const RANGE = /^\/([A-Za-z0-9_-]{3,100})\//
const MISSION_FULL = `Mission journal reached the ${MISSION_MAX_EVENTS}-event safety limit of this mission`
const RANGE_OVER_BOUND = `Mission journal exceeds the ${MISSION_MAX_STORED_EVENTS}-event safety limit of one mission`
type CapacityIntent = Extract<MissionEvent, { type: "mission.deleted" | "mission.control-requested" }>
interface CapacitySlot { intent: CapacityIntent; sessionID: string }
/** `unreadable`: missions whose deletion record could not be read, kept as damage. */
@ -146,13 +148,12 @@ export class MissionJournal {
private async observe(prefix = this.prefix()): Promise<Observation> {
const events: MissionEvent[] = []
let discardedEvents = 0
const bound = this.rangeBound()
let after: string | undefined
do {
const page = await this.storage.scan({ prefix, after, limit: PAGE_SIZE })
for (const entry of page.entries) {
if (events.length + discardedEvents >= MISSION_MAX_STORED_EVENTS) {
throw new Error(`Mission journal exceeds the ${MISSION_MAX_STORED_EVENTS}-event safety limit`)
}
bound(entry.key)
const event = this.parseEntry(entry.key, entry.value)
if (!event) discardedEvents += 1
else events.push(event)
@ -162,19 +163,32 @@ export class MissionJournal {
return this.resolve(events, discardedEvents)
}
/** No mission range, nor the entries outside every range together, is read
* past `MISSION_MAX_STORED_EVENTS`; a whole project has no bound of its own.
* Returns the counted key's mission. */
private rangeBound(): (key: string) => string | undefined {
const prefix = this.prefix(), counts = new Map<string, number>()
return key => {
const missionID = key.startsWith(prefix) ? RANGE.exec(key.slice(prefix.length))?.[1] : undefined
const count = (counts.get(missionID ?? "") ?? 0) + 1
if (count > MISSION_MAX_STORED_EVENTS) throw new Error(RANGE_OVER_BOUND)
counts.set(missionID ?? "", count)
return missionID
}
}
/** The index build: one key-ordered scan of the project, resolved and reduced
* one mission range at a time like `observeMission`, under the same read bound.
* Returns the entries outside every range. */
private async scanRanges(visit: (range: MissionIndexRange) => void): Promise<number> {
const prefix = this.prefix()
let stray = 0, count = 0, group: { missionID: string; events: MissionEvent[]; discardedEvents: number } | undefined
const prefix = this.prefix(), bound = this.rangeBound()
let stray = 0, group: { missionID: string; events: MissionEvent[]; discardedEvents: number } | undefined
const flush = async () => { if (group) visit(this.range(group.missionID, await this.resolve(group.events, group.discardedEvents))) }
let after: string | undefined
do {
const page = await this.storage.scan({ prefix, after, limit: PAGE_SIZE })
for (const entry of page.entries) {
if (++count > MISSION_MAX_STORED_EVENTS) throw new Error(`Mission journal exceeds the ${MISSION_MAX_STORED_EVENTS}-event safety limit`)
const missionID = entry.key.startsWith(prefix) ? RANGE.exec(entry.key.slice(prefix.length))?.[1] : undefined
const missionID = bound(entry.key)
if (!missionID) { stray++; continue }
if (group?.missionID !== missionID) { await flush(); group = { missionID, events: [], discardedEvents: 0 } }
const event = this.parseEntry(entry.key, entry.value)
@ -276,23 +290,23 @@ export class MissionJournal {
return parsed
}
async assertCanAppend(count: number | MissionEvent = 1): Promise<void> {
const event = typeof count === "number" ? undefined
: this.parseEntry(this.eventKey(count.missionID, count.id), JSON.parse(JSON.stringify(count)))
if (typeof count !== "number" && !event) throw new Error("Mission event is not durable owned JSON")
const request = event ?? count as number
if (await this.fits(request)) return
if (this.storage.remove && await this.purgeDeleted({ all: true }) && await this.fits(request)) return
throw new Error(`Mission journal reached the ${MISSION_MAX_EVENTS}-event safety limit`)
/** Refuses, before any side effect, an `event` (or `count` more ordinary entries
* of mission `request`) that its mission's range cannot hold. */
async assertCanAppend(request: MissionEvent | string, count = 1): Promise<void> {
const event = typeof request === "string" ? undefined
: this.parseEntry(this.eventKey(request.missionID, request.id), JSON.parse(JSON.stringify(request)))
if (typeof request !== "string" && !event) throw new Error("Mission event is not durable owned JSON")
if (!await this.fits(event?.missionID ?? request as string, event ?? count)) throw new Error(MISSION_FULL)
}
private async fits(request: MissionEvent | number): Promise<boolean> {
const capacity = await this.storedCapacity()
/** Capacity is one mission's own: only its range is read, whatever the project holds. */
private async fits(missionID: string, request: MissionEvent | number): Promise<boolean> {
const capacity = await this.storedCapacity(missionID)
const slots = capacity.cleanupSlots
const required = typeof request === "number" ? request : this.requiredCapacity(request, slots)
// Deletion intents and already reserved receipts may spend the deletion reserve.
const limit = this.storage.remove && typeof request !== "number"
&& (request.type === "mission.deleted" || this.matchesCapacityReceipt(request, slots)) ? MISSION_MAX_STORED_EVENTS : MISSION_MAX_EVENTS
// Deletion intents and already reserved receipts may spend the mission's deletion reserve.
const limit = typeof request !== "number" && (request.type === "mission.deleted" || this.matchesCapacityReceipt(request, slots))
? MISSION_MAX_STORED_EVENTS : MISSION_MAX_EVENTS
return capacity.count + slots.size + required <= limit
}
@ -308,9 +322,7 @@ export class MissionJournal {
if (JSON.stringify(existing) !== JSON.stringify(stored)) throw new Error("Mission event identity collision")
return
}
if (!await this.fits(parsed) && !(this.storage.remove && await this.purgeUnlocked(true) && await this.fits(parsed))) {
throw new Error(`Mission journal reached the ${MISSION_MAX_EVENTS}-event safety limit`)
}
if (!await this.fits(parsed.missionID, parsed)) throw new Error(MISSION_FULL)
this.written(parsed.missionID)
current?.()
await this.storage.set(key, stored, current)
@ -318,8 +330,8 @@ export class MissionJournal {
private requiredCapacity(event: MissionEvent, slots: Map<string, CapacitySlot>): number {
if (this.matchesCapacityReceipt(event, slots)) return 0
// A newer Stop replaces only this mission's pending start/pause slots.
// Physical events, cleanup reservations and other missions remain counted.
// A newer Stop replaces only its pending start/pause slots; physical events
// and cleanup reservations remain counted.
const superseded = event.type === "mission.control-requested" && event.action === "stop"
? [...slots.values()].filter(({ intent }) => intent.type === "mission.control-requested" && intent.action !== "stop"
&& intent.projectID === event.projectID && intent.missionID === event.missionID
@ -329,7 +341,8 @@ export class MissionJournal {
+ (event.type === "mission.control-requested" ? event.targets.length : 0) - superseded
}
private async storedCapacity(): Promise<{ count: number; cleanupSlots: Map<string, CapacitySlot> }> {
private async storedCapacity(missionID: string): Promise<{ count: number; cleanupSlots: Map<string, CapacitySlot> }> {
const prefix = `${this.prefix()}/${safeKey(missionID)}/`
let count = 0
// Physical deterministic receipt keys include mission scope. Retain the
// original intent/target as well: a different event kind cannot spend them.
@ -343,9 +356,9 @@ export class MissionJournal {
}
let after: string | undefined
do {
const page = await this.storage.scan({ prefix: this.prefix(), after, limit: PAGE_SIZE })
const page = await this.storage.scan({ prefix, after, limit: PAGE_SIZE })
count += page.entries.length
if (count > MISSION_MAX_STORED_EVENTS) throw new Error(`Mission journal exceeds the ${MISSION_MAX_STORED_EVENTS}-event safety limit`)
if (count > MISSION_MAX_STORED_EVENTS) throw new Error(RANGE_OVER_BOUND)
for (const entry of page.entries) {
// Invalid entries still consume physical space, but supply no authority
// to establish, supersede or discharge a reservation.

View file

@ -11,12 +11,12 @@ import { projectLifecycle, type MissionControlRequestedEvent, type MissionContro
export const MISSION_SCHEMA_VERSION = 1 as const
export const MISSION_MAX_ACTORS = 8
/** Ordinary entries of one mission's journal range; a project holds any number of missions. */
export const MISSION_MAX_EVENTS = 2_000
/** Room above the ordinary limit for deletion tombstones and their cleanup
* receipts, so a full project can still delete; purging settled deletions then
* frees the space. Only purge-capable storage spends it. */
/** Room above a mission's ordinary limit for its deletion tombstone and reserved
* receipts, so a full mission can still be deleted. */
export const MISSION_DELETION_RESERVE = 4 * (1 + MISSION_MAX_ACTORS)
/** Physical read bound of one project journal. */
/** Physical read bound of one mission's journal range. */
export const MISSION_MAX_STORED_EVENTS = MISSION_MAX_EVENTS + MISSION_DELETION_RESERVE
export const MISSION_MAX_TASKS = 96
export const MISSION_MAX_HISTORY = 50

View file

@ -100,7 +100,7 @@ for (const damage of ["noncanonical", "physical-key", "event", "mission", "proje
assert.equal((projected as any).controlUnavailable, true)
assert.deepEqual(projected.control!.pending, operation.targets.map(target => target.sessionID))
assert.equal(projected.control!.id, operation.id); assert.equal(projected.control!.requestID, request.requestID)
await assert.rejects(f.journal.assertCanAppend(MISSION_MAX_EVENTS - f.values.size), /safety limit/)
await assert.rejects(f.journal.assertCanAppend(mission.id, MISSION_MAX_EVENTS - f.values.size), /safety limit/)
f.allowControl()
await assert.rejects(f.control.lifecycle(request))
await assert.rejects(f.control.lifecycle({ ...request, requestID: "new-stop", action: "stop", expectedRevision: projected.revision }))

View file

@ -3,8 +3,7 @@ import { missionProfilesSchema } from "./playbook-profiles"
import { missionTaskModeSchema } from "./task-execution-mode"
import { missionMutationErrors } from "./rpc-errors"
import { lifecycleInputSchema, lifecycleOperationSchema } from "./lifecycle-schema"
import { missionCleanupSchema } from "./cleanup-projection"
import { MISSION_MAX_EVENTS } from "./model"
import { MAX_PROJECTED_CLEANUPS, missionCleanupSchema } from "./cleanup-projection"
import { MISSION_TITLE_MAX } from "./mission-title"
import { missionBriefingResponsesSchema, missionBriefingSnapshotSchema } from "./briefing"
import { taskExecutionModeRpcSchema, taskGenerationSchema, nativeTaskBindingSchema, nativeTaskExecutionSchema, nativeCallBindingSchema } from "./native-wire-schema"
@ -281,7 +280,7 @@ export const CODENOMAD_MISSIONS_RPC = {
generatedAt: { type: "number" },
missions: { type: "array", items: mission },
discardedEvents: { type: "number" },
cleanups: { type: "array", maxItems: MISSION_MAX_EVENTS, items: missionCleanupSchema },
cleanups: { type: "array", maxItems: MAX_PROJECTED_CLEANUPS, items: missionCleanupSchema },
cleanupUnavailable: { type: "boolean" },
controlUnavailable: { type: "boolean" },
notificationUnavailable: { type: "boolean" },

View file

@ -298,22 +298,25 @@ test("legacy tombstones parse, malformed destructive intent is rejected", () =>
assert.ok(parseMissionEvent({ ...legacy, deleteManagedSessions: true, cleanupTargets: [] }))
})
test("pending cleanup reserves its journal receipts against unrelated writes at the event limit", async () => {
test("pending cleanup reserves its journal receipts against unrelated writes at the mission's event limit", async () => {
const f = fixture()
const mission = await f.delegate(await f.create(), "worker")
const id = specialist(mission)
for (let index = f.values.size; index < MISSION_MAX_EVENTS - 2; index++) {
const event = { version: 1, id: `evt_fill_${index}`, type: "task.dispatched", missionID: "msn_fill", projectID: "project-1", taskKey: "unused", createdAt: 1 }
f.values.set(`${MISSION_JOURNAL_STORAGE_PREFIX}/${f.journal.projectToken}/msn_fill/${event.id}`, event)
const range = `${MISSION_JOURNAL_STORAGE_PREFIX}/${f.journal.projectToken}/${mission.id}/`
const own = () => [...f.values.keys()].filter(key => key.startsWith(range)).length
for (let index = own(); own() < MISSION_MAX_EVENTS - 2; index++) {
const event = { version: 1, id: `evt_fill_${index}`, type: "task.dispatched", missionID: mission.id, projectID: "project-1", taskKey: "unused", createdAt: 1 }
f.values.set(`${range}${event.id}`, event)
}
f.failing.add(id)
const request = f.deletion(mission, true)
const current = (await f.control().snapshot()).missions[0]
const request = f.deletion(current, true)
await assert.rejects(f.control().delete(request), code("cleanup-pending"))
assert.equal(f.values.size, MISSION_MAX_EVENTS - 1)
await assert.rejects(f.journal.append({ version: 1, id: "evt_unrelated", type: "task.dispatched", missionID: "msn_fill", projectID: "project-1", taskKey: "unused", createdAt: 1 }), /safety limit/)
assert.equal(own(), MISSION_MAX_EVENTS - 1)
await assert.rejects(f.journal.append({ version: 1, id: "evt_unrelated", type: "task.dispatched", missionID: mission.id, projectID: "project-1", taskKey: "unused", createdAt: 1 }), /safety limit/)
f.failing.clear()
await f.control().delete(request)
assert.equal(f.values.size, MISSION_MAX_EVENTS)
assert.equal(own(), MISSION_MAX_EVENTS)
await f.control().delete(request)
assert.deepEqual(f.removed, [id])
})

View file

@ -53,12 +53,7 @@ export async function deleteMission(input: MissionDeleteInput, options: {
const complete = await cleanup(deletion, options)
// Receipts change cleanup display even though the active map is tombstoned.
await options.changed(input.missionID, input.expectedRevision + 1)
if (!complete) {
// A capacity purge may already have settled and removed this tombstone.
const settled = await purgedDeletion(journal, input, eventID, deleteManagedSessions)
if (settled) return settled
throw new MissionControlError("Mission deleted; managed-session cleanup is pending. Retry the same deletion request.", "cleanup-pending")
}
if (!complete) throw new MissionControlError("Mission deleted; managed-session cleanup is pending. Retry the same deletion request.", "cleanup-pending")
}
if (!deleteManagedSessions) {
await purgeSettled(journal)
@ -70,10 +65,6 @@ export async function deleteMission(input: MissionDeleteInput, options: {
}
const summary = projectMissionCleanups(history.events.filter(event => event.missionID === deletion.missionID))
.find(item => item.deletionID === deletion.id)
if (!summary) {
const settled = await purgedDeletion(journal, input, eventID, deleteManagedSessions)
if (settled) return settled
}
if (!summary || summary.pending) throw new MissionControlError("Cannot confirm managed-session cleanup", "cleanup-pending")
await purgeSettled(journal)
return { deleted: true, cleanup: summary }

View file

@ -118,7 +118,7 @@ test("explicit old-key writer cannot change fresh maps, signed grants/receipts o
return mission && { missionID, coordinatorSessionID: mission.coordinatorSessionId, revision: mission.revision,
status: mission.status, runState: mission.runState!, controlPending: Boolean(mission.control?.pending.length),
control: mission.control, controlUnavailable: mission.controlUnavailable, roots }
}, assertJournalCapacity: async () => journal.assertCanAppend(2),
}, assertJournalCapacity: async intent => journal.assertCanAppend(intent.missionID, 2),
})
const requestID = "fresh-create"
const missionID = `msn_${stableToken(`${projectID}\0${requestID}`, 24)}`

View file

@ -704,8 +704,34 @@ try {
const finished = await invoke("report", { missionID: created.mission.id, outcome: "completed", summary: `Finished ${index}`, final: true },
created.mission.coordinatorSessionId, `history-${index}`)
assert.equal(finished.mission.status, "completed")
history.push(created.mission)
history.push(finished.mission)
}
// The journal bound is per mission: the finished missions' own ranges, seeded in
// the isolated KV table as an earlier activation left them, take the project past
// one mission's 2,036-entry bound while each mission stays far below it.
const { DatabaseSync } = await import("node:sqlite")
const namespace = `plugin:${Array.from("codenomad.missions").map(c => c.charCodeAt(0).toString(16).padStart(4, "0")).join("")}:`
const journalEntries = (database, range) => database.prepare("SELECT COUNT(*) AS count FROM kv WHERE substr(key, 1, ?) = ?").get(range.length, range).count
let journalRange
const seeding = new DatabaseSync(process.env.OPENCODE_DB)
try {
seeding.exec("PRAGMA busy_timeout = 10000")
const historyKey = seeding.prepare("SELECT key FROM kv WHERE key LIKE ? LIMIT 1").get(`${namespace}codenomad-missions/v2/%/${history[0].id}/%`)?.key
assert.ok(historyKey, "The finished missions' journal ranges are in the isolated native KV table")
journalRange = `${historyKey.slice(0, historyKey.indexOf(`/${history[0].id}/`))}/`
const insert = seeding.prepare("INSERT INTO kv(key,value,time_created,time_updated) VALUES(?,?,?,?)")
// One transaction: thousands of autocommits would idle the client's keep-alive connection past the daemon's timeout.
seeding.exec("BEGIN IMMEDIATE")
for (const mission of history) {
for (let index = 0; index < 22; index++) {
const id = `evt_seeded_${String(index).padStart(2, "0")}`
insert.run(`${journalRange}${mission.id}/${id}`, JSON.stringify({ version: 1, id, type: "mission.updated", missionID: mission.id, projectID: mission.projectID,
createdAt: mission.createdAt + 1 + index, requestID: `seeded-${index}`, expectedRevision: 1, notesSpecified: false, objective: `Seeded ${index}` }), Date.now(), Date.now())
}
}
seeding.exec("COMMIT")
assert.ok(journalEntries(seeding, journalRange) > 2_036, "The project holds more than one mission's bound")
} finally { seeding.close() }
await client.location.reload({ signal: AbortSignal.timeout(20_000) })
await until(async () => { try { return Boolean(await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })) } catch { return false } })
const live = (await client.rpc(CODENOMAD_MISSIONS_RPC).create({ requestID: "native-history-live", objective: "Live beside history", template: "custom" }, { location })).mission
@ -714,19 +740,26 @@ try {
assert.equal((await invoke("delegate", liveTask, liveCoordinator, "history-declare")).disposition, "declared")
assert.equal((await invoke("report", { missionID: live.id, taskKey: liveTask.taskKey, outcome: "completed", summary: "History unaffected" },
liveCoordinator, "history-readout")).disposition, "reported")
const beforeDelete = await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })
assert.equal(beforeDelete.missions.find(mission => mission.id === history[0].id).revision, history[0].revision + 22, "Seeded entries read as the mission's own events")
// The deletion's retention purge pushes the oldest settled deletion past the history, through native storage.remove.
const settled = beforeDelete.cleanups.filter(item => item.pending === 0)
assert.equal(settled.length, 20)
assert.deepEqual(await client.rpc(CODENOMAD_MISSIONS_RPC).delete({ missionID: history[0].id, requestID: "native-history-delete",
expectedRevision: (await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })).missions.find(mission => mission.id === history[0].id).revision }, { location }), { deleted: true })
expectedRevision: beforeDelete.missions.find(mission => mission.id === history[0].id).revision }, { location }), { deleted: true })
const withHistory = await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })
assert.equal(withHistory.missions.filter(mission => history.some(item => item.id === mission.id)).length, history.length - 1)
assert.equal(withHistory.discardedEvents, 0)
assert.equal(withHistory.cleanups.length, 20)
assert(!withHistory.cleanups.some(item => item.missionID === settled.at(-1).missionID), "The oldest settled deletion was purged")
// Probe which reads see the whole journal: a damaged entry written beside the
// bundle (as another process would) is visible to full reads at once and to
// the activation's index only after its next rebuild, a minute away.
const { DatabaseSync } = await import("node:sqlite")
const database = new DatabaseSync(process.env.OPENCODE_DB)
try {
database.exec("PRAGMA busy_timeout = 10000")
const namespace = `plugin:${Array.from("codenomad.missions").map(c => c.charCodeAt(0).toString(16).padStart(4, "0")).join("")}:`
assert.equal(journalEntries(database, `${journalRange}${settled.at(-1).missionID}/`), 0, "Native storage.remove cleared the purged range")
assert.ok(journalEntries(database, journalRange) > 2_036, "The project still holds more than one mission's bound")
const liveKey = database.prepare("SELECT key FROM kv WHERE key LIKE ? LIMIT 1").get(`${namespace}codenomad-missions/v2/%/${live.id}/%`)?.key
assert.ok(liveKey, "The live mission's journal range is in the isolated native KV table")
const strayKey = `${liveKey.slice(0, liveKey.indexOf(`/${live.id}/`))}/stray-history-probe`
@ -749,7 +782,7 @@ try {
assert.equal((await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location })).discardedEvents, 0)
assert.equal((await invoke("delegate", { ...liveTask, taskKey: "history-refused" }, liveCoordinator, "history-accepted")).disposition, "declared",
"Only the damage refused the declaration: its authoritative gate read the entry the hints had not seen")
gate("finished history: creation, declaration, readout and deletion beside it; hinted reads skip it, write gates read it")
gate("finished history beyond one mission's bound: creation, declaration, readout, deletion and purge beside it; hinted reads skip it, write gates read it")
assert.equal(failures.length, 0, "Every baseline expectation must pass; collected errors are never skipped or accepted")
receipt.status = "passed"; receipt.passCount = gates.length
receipt.finalSnapshot = await client.rpc(CODENOMAD_MISSIONS_RPC).snapshot({}, { location }); receipt.providerRequests = requests.length