From f65b77fcfceefca74337a3ba802e8a28b70603ef Mon Sep 17 00:00:00 2001 From: "Instar Agent (echo)" Date: Thu, 28 May 2026 18:50:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(monitoring):=20CrossSessionCoordinator=20?= =?UTF-8?q?=E2=80=94=20light=20advisory=20cross-session=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent Claude Code sessions on one agent home share `.instar/` state but are blind to each other. The damaging incident (2026-05-28): one session built a fix while another flipped a feature flag off + mass-withdrew 19 commitments — neither knew the other was acting. Justin approved a LIGHT fix (topic 15579): a visible "I'm about to do X" signal, no hard locks; "learn to collaborate slowly." CrossSessionCoordinator: a shared append-only scratchpad of recent high-impact structural actions + voluntary intents. Any structural action surfaces other recent entries by a different/unknown session as an advisory `coordinationWarning`. It never blocks and never mutates target state — purely advisory. - Routes: POST /coordination/intent, GET /coordination/recent - Backstop auto-record: sensitive PATCH /config flips + commitment withdrawals, attaching coordinationWarning to the action's own HTTP response - Default-ON housekeeping, near-silent (no Telegram); audit at logs/cross-session-events.jsonl - Wired in AgentServer (always alive → GET 200 not 503) + CapabilityIndex entry - Migration parity: ConfigDefaults default + migrateClaudeMd awareness section + generateClaudeMd template - Docs: CrossSessionCoordinator + /coordination routes in the docs site (docs-coverage) - Tests: unit (16) + integration (8) + e2e lifecycle (6) + migration parity (5) Spec: docs/specs/cross-session-coordination.md (converged + approved, topic 15579) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/cross-session-coordination.eli16.md | 75 +++++ docs/specs/cross-session-coordination.md | 129 ++++++++ .../cross-session-coordination-convergence.md | 77 +++++ .../docs/architecture/under-the-hood.md | 3 + site/src/content/docs/reference/api.md | 11 + src/config/ConfigDefaults.ts | 15 + src/core/PostUpdateMigrator.ts | 25 ++ src/data/builtin-manifest.json | 132 ++++---- src/monitoring/CrossSessionCoordinator.ts | 287 ++++++++++++++++++ src/scaffold/templates.ts | 8 + src/server/AgentServer.ts | 27 ++ src/server/CapabilityIndex.ts | 13 + src/server/routes.ts | 123 +++++++- ...oss-session-coordination-lifecycle.test.ts | 122 ++++++++ .../cross-session-coordination-routes.test.ts | 189 ++++++++++++ tests/unit/ConfigDefaults.test.ts | 15 + ...eMigrator-crossSessionCoordination.test.ts | 119 ++++++++ tests/unit/cross-session-coordinator.test.ts | 192 ++++++++++++ upgrades/NEXT.md | 66 ++++ .../cross-session-coordination.md | 94 ++++++ 20 files changed, 1655 insertions(+), 67 deletions(-) create mode 100644 docs/specs/cross-session-coordination.eli16.md create mode 100644 docs/specs/cross-session-coordination.md create mode 100644 docs/specs/reports/cross-session-coordination-convergence.md create mode 100644 src/monitoring/CrossSessionCoordinator.ts create mode 100644 tests/e2e/cross-session-coordination-lifecycle.test.ts create mode 100644 tests/integration/cross-session-coordination-routes.test.ts create mode 100644 tests/unit/PostUpdateMigrator-crossSessionCoordination.test.ts create mode 100644 tests/unit/cross-session-coordinator.test.ts create mode 100644 upgrades/NEXT.md create mode 100644 upgrades/side-effects/cross-session-coordination.md diff --git a/docs/specs/cross-session-coordination.eli16.md b/docs/specs/cross-session-coordination.eli16.md new file mode 100644 index 000000000..9022084a9 --- /dev/null +++ b/docs/specs/cross-session-coordination.eli16.md @@ -0,0 +1,75 @@ +# Cross-Session Coordination — plain-English overview + +## What this is, in one line + +When more than one copy of me is running at the same time on the same machine, this +gives them a way to *see each other* before they each do something big — so they +stop stepping on each other's toes. + +## Why we need it (the real story) + +I can have several sessions running at once against the same set of files (my +`.instar/` folder). Think of it like several cooks in one kitchen, all following the +same recipe, none of them aware the others are there. Two things actually went wrong +on 2026-05-28: + +1. **The "still working" ghost.** One session finished a job but left a sticky note + saying "still cooking: yes." A second session read that stale note and kept telling + the user "I'm still working on it" — even though nothing was happening. +2. **The double-reaction (the bad one).** Both sessions noticed the same bug. One + calmly built the proper fix. The *other* panicked and hit the emergency brake — + flipped a feature off and cancelled 19 pending tasks. Neither knew the other was on + it. Result: the bug got fixed, but the engine was left off and the test setup was + wiped. Two correct instincts that, uncoordinated, undid each other. + +The root cause is the same both times: **shared files, no traffic light.** Nothing +tells one session "hey, another you is already acting — wait a second." + +## What we are building (and just as importantly, what we're NOT) + +Justin chose the **light** option from a menu of light / medium / heavy. So this is +the gentle version, on purpose: + +- **What it does:** Before a session does something high-impact (flip a feature flag, + withdraw commitments), it can post a quick note — "I'm about to do X." Whenever any + session takes such an action, the system hands back a little advisory warning if + *another* session was just active: "⚠ another session withdrew 19 commitments 4 + minutes ago — confirm before proceeding." +- **What it does NOT do:** It never blocks anything. There are no hard locks. It never + changes the thing you were trying to change. It's a heads-up, not a gate. If a + session ignores the warning, the action still goes through. + +That matches exactly what Justin asked for: "start small and learn to collaborate +slowly and smoothly," with cheap course-correction. + +## How you'd actually see it + +- A session announces intent: `POST /coordination/intent`. +- A session inspects what's been happening: `GET /coordination/recent`. +- The two risky actions from the incident — config-flag flips and commitment + withdrawals — automatically record themselves and carry the warning back in their + own response, so it works even if a session forgets to announce. +- It's quiet: no Telegram pings. Everything is logged to + `logs/cross-session-events.jsonl` for later reading. + +## What already exists vs. what's new + +- **Already existed:** Single-commitment write safety (`CommitmentTracker.mutate()` + uses a compare-and-swap so two sessions can't tear one record). That protects a + single write — it does *not* stop two sessions adopting *opposite plans*. +- **New here:** the cross-session visibility layer — the shared scratchpad + the + advisory warning that surfaces an opposing action before you commit to yours. + +## What's deliberately left for later + +- The stale "still working" ghost (incident #1) is a *separate* liveness bug, not a + coordination problem. It's noted as a candidate next step, not bundled in here. +- Hard locks / leader election / one-session-in-charge — that's the heavy redesign + Justin chose *not* to do for now. + +## What the reader needs to decide + +Really just one thing: is "light and advisory" the right first step, or do you want +something stronger (real locks) sooner? Justin already said light-first. If watching +it in practice shows the advisory isn't enough, the medium/heavy options are still on +the table — nothing here paints us into a corner. diff --git a/docs/specs/cross-session-coordination.md b/docs/specs/cross-session-coordination.md new file mode 100644 index 000000000..bfd3c169a --- /dev/null +++ b/docs/specs/cross-session-coordination.md @@ -0,0 +1,129 @@ +--- +title: Cross-Session Coordination Signal (light, advisory) +status: approved +approved: true +approver: justin +approved-at: "2026-05-28T19:00:00Z" +review-convergence: "2026-05-28T19:00:00Z" +review-iterations: 3 +review-completed-at: "2026-05-28T19:00:00Z" +review-report: "docs/specs/reports/cross-session-coordination-convergence.md" +created: 2026-05-28 +owner: echo +eli16-overview: cross-session-coordination.eli16.md +--- + +# Cross-Session Coordination Signal (light, advisory) + +> Approved direction: Justin, 2026-05-28 topic 15579 — "go with a light fix for now +> and see if it helps … learn to collaborate slowly and smoothly." This is the +> written form of the build Justin chose from the light/medium/heavy menu and saw +> described before approving. Convergence review: +> docs/specs/reports/cross-session-coordination-convergence.md. + +## The problem (two observed incidents, 2026-05-28) + +A single agent home can have **multiple concurrent Claude Code sessions** acting on +the same `.instar/` state. They are blind to each other. Two real incidents: + +1. **Stale `active:true` ghost** — an autonomous-state file said `active:true` after + the work had completed; a *different* session read it as "still in flight" and + narrated "working." (Recovered easily. Treated as a SEPARATE staleness/liveness + bug — out of scope for this signal; see "Out of scope".) +2. **Opposing durable actions (the damaging one)** — one session built the proper fix + (PR 495); in parallel another session hit a "safety brake": flipped a config flag + to `false` AND mass-withdrew ~19 active commitments. Both reached a correct local + diagnosis; neither knew the other was acting. Net: bugs fixed, engine off, test + bed gone. + +Root cause: shared mutable state (`.instar/config.json`, commitments, autonomous +state) with **no cross-session visibility before a high-impact action**. +`CommitmentTracker.mutate()` has single-writer CAS *per commitment* — that prevents +torn writes, not two sessions adopting opposing policies and each acting durably. + +## The light fix (this build) + +A **CrossSessionCoordinator**: a shared, append-only scratchpad of recent +high-impact "structural actions" + voluntary "I'm about to do X" intents. Any +structural action surfaces *other recent* entries to the actor as an advisory +`coordinationWarning`. **It never blocks** (light = advisory). It never mutates the +target state. It is the "visible signal sessions check before acting" Justin asked for. + +### What gets recorded +- **Voluntary intent** (`POST /coordination/intent`) — a session announces what it is + about to do / is doing ("building PR 495 fix for the redrive flood"). This is the + primary "I'm about to do X" surface. +- **config-flag flip** — auto-recorded at the `PATCH /config` route for sensitive + keys (feature on/off toggles). Backstop; no agent reliance. +- **commitment-withdraw** — auto-recorded in the `POST /commitments/:id/withdraw` + route handler after a successful withdrawal. All agent-initiated withdrawals are + route-driven (the route is the single agent-facing path), so recording there is + the single source — no double-count — and it lets the warning ride back on the + same HTTP response. (Code-internal lifecycle transitions like expiry are not + withdrawals and are deliberately not recorded.) + +### The signal +On every recorded action, the coordinator computes `concurrent`: other non-expired +records within `windowMs` (default 10 min) authored by a *different or unknown* actor. +If non-empty, the acting route attaches a `coordinationWarning` to its HTTP response +("⚠ N recent structural action(s) by another/unknown session in the last Xm: … — +confirm before proceeding"). The session (Claude) reads that warning inline and can +reconsider, re-check, or tell the user. `GET /coordination/recent` exposes the ledger +for explicit pre-action checks and inspection. + +### Storage + audit +- Ledger: `/state/cross-session-actions.json` — `{ version, actions: [...] }`, + capped (200), TTL-pruned on write (`retentionMs`, default 60 min). Atomic temp+rename, + reload-per-op (cross-process safe, mirrors ConversationStore). +- Audit: append each record to `/logs/cross-session-events.jsonl`. + +### Config (default-ON housekeeping; matches the silently-stopped sentinels) +``` +monitoring.crossSessionCoordination: { + enabled: true, // records + warns. false => passive (GET still 200, no records/warnings) + windowMs: 600000, // concurrency window (10 min) + retentionMs: 3600000, // ledger TTL (60 min) + sensitiveConfigKeys: ["monitoring", "tunnel", "autonomousSessions", "lifeline", "updates"] +} +``` +No Telegram escalation in v1 (deliberate — near-silent; in-response warning + GET + +JSONL is full observability without topic-spam risk). A buzz-on-conflict toggle is a +small future addition if it proves wanted . + +## Wiring +- `CrossSessionCoordinator` constructed inside `AgentServer` (like FrameworkIssueLedger / + TokenLedger — always alive, so `GET /coordination/recent` returns 200 not 503), + in its own try/catch so a failure never cascades into the other monitors. +- Added to `RouteContext` as `crossSessionCoordinator`. (NOTE: distinct from the + multi-MACHINE `coordinator` already on ctx.) +- Actor resolution (`coordinationActor`) is best-effort and SESSION-level only + (`X-Instar-Session` / `X-Instar-Actor` header, or body `actor`) — never the agent + id, which all sessions of one agent share and would wrongly suppress the warning. +- `PATCH /config`: record sensitive-key flips, attach `coordinationWarning`. +- `POST /commitments/:id/withdraw`: after success, attach `coordinationWarning`. +- Routes: `POST /coordination/intent`, `GET /coordination/recent`. + +## Migration parity +- Config defaults: add `monitoring.crossSessionCoordination` to `ConfigDefaults.ts` + `SHARED_DEFAULTS` → auto-applied to existing agents via `applyDefaults` in + `PostUpdateMigrator.migrateConfig`. No bespoke migration needed. +- CLAUDE.md template: add a Cross-Session Coordination awareness section to + `generateClaudeMd()` + `migrateClaudeMd()` content-sniff guard. + +## Tests (all three tiers — NON-NEGOTIABLE) +- **Unit** (`tests/unit/cross-session-coordinator.test.ts`): record/prune/window, + concurrent detection (different vs same vs unknown actor), cap, atomic persistence, + reload-per-op, disabled mode. +- **Integration** (`tests/integration/cross-session-coordination-routes.test.ts`): + POST /coordination/intent → GET /coordination/recent; PATCH /config sensitive key + returns `coordinationWarning` when a concurrent intent exists; withdraw warning; + auth required. +- **E2E** (`tests/e2e/cross-session-coordination-lifecycle.test.ts`): boot real + AgentServer (production path), GET /coordination/recent returns 200 (alive), a + recorded action surfaces end-to-end, capability discoverable. + +## Out of scope (separable, flagged to Justin) +- Incident #1 stale-`active:true` cleanup/liveness guard — a distinct staleness bug, + not a coordination signal. Candidate next step. +- Hard locks / leader election / session registry — explicitly the *heavy* path + Justin declined for now. diff --git a/docs/specs/reports/cross-session-coordination-convergence.md b/docs/specs/reports/cross-session-coordination-convergence.md new file mode 100644 index 000000000..900022602 --- /dev/null +++ b/docs/specs/reports/cross-session-coordination-convergence.md @@ -0,0 +1,77 @@ +# Convergence Report — Cross-Session Coordination Signal (light, advisory) + +- Spec: `docs/specs/cross-session-coordination.md` +- Owner: echo +- Approved direction: Justin, topic 15579, 2026-05-28 ("go with a light fix for now … + learn to collaborate slowly and smoothly"), chosen from an explicit + light / medium / heavy menu, with the build described before approval. +- Iterations: 3 substantive design-review passes (below). + +## Scope under review + +A LIGHT, advisory cross-session signal: a shared append-only scratchpad of recent +high-impact structural actions + voluntary "I'm about to do X" intents, surfaced to +an acting session as an in-response `coordinationWarning`. Never blocks, never mutates +target state. Explicitly NOT hard locks / leader election (the heavy path Justin +declined for now). + +## Iteration 1 — initial design + +Settled the storage model (atomic temp+rename JSON ledger, reload-per-op for +cross-process safety, TTL prune + hard cap), the action taxonomy +(`intent` / `config-flag` / `commitment-withdraw` / `other`), and the advisory-only +contract (record + warn, never block, never touch the target). Decided default-ON +housekeeping with no Telegram escalation in v1 — the in-response warning + `GET +/coordination/recent` + JSONL audit give full observability without re-introducing +the topic-spam risk that the attention-flood work just closed. + +## Iteration 2 — implementation review (caught real issues) + +1. **Double-count reconciliation (commitment-withdraw recording site).** The first + design proposed subscribing to `CommitmentTracker`'s `withdrawn` event AND recording + in the withdraw route — which double-counts. Resolved to a single source: record in + the `POST /commitments/:id/withdraw` route handler. Rationale: all *agent-initiated* + withdrawals are route-driven (the route is the single agent-facing path), so this is + the single source, gives no double-count, and lets the warning ride back on the same + HTTP response. Code-internal lifecycle transitions (expiry) are not withdrawals and + are deliberately not recorded. Spec updated to match. + +2. **Actor-resolution correctness.** Decided `coordinationActor()` must resolve a + SESSION-level discriminator only (`X-Instar-Session` / `X-Instar-Actor` header, or + body `actor`) and must NEVER fall back to the agent id — every session of one agent + shares the agent id, so using it would wrongly suppress the very cross-session + warning we want. Unknown actor is treated as "potentially a different session" so the + signal errs toward surfacing. + +3. **Intent-dedup bug (found via the integration test, fixed).** The action-identity + used for dedupe was `kind + target + value`. Two *different* intents both have no + target/value, so they collapsed into "the same action" and the warning was wrongly + suppressed. Fixed: intents are events, not states — they are never deduped. Dedupe + now applies only to state-flip kinds (config-flag / commitment-withdraw / other), + where two sessions writing the identical kind+target+value genuinely are one write. A + regression unit test locks the boundary. + +## Iteration 3 — testing-integrity + standards pass + +- All three tiers built and green: unit (16), integration (8, exercising both real + incident vectors — config-flip + withdraw over live HTTP), e2e lifecycle (6, booting + the real AgentServer → feature alive, 200 not 503), migration-parity (5). +- Migration parity: config default in `ConfigDefaults.SHARED_DEFAULTS` + (applyDefaults propagates to existing agents), `migrateClaudeMd` awareness section + + `generateClaudeMd` template, CapabilityIndex entry. Unit-tested. +- No-silent-fallbacks: the coordinator's advisory catch blocks carry + `@silent-fallback-ok` markers (persistence failure must never break a calling route); + verified the file contributes zero to the lint count. + +## Open / deferred + +- Incident #1 (stale `active:true` liveness ghost) — a distinct staleness bug, not a + coordination signal; candidate next step . +- Buzz-on-conflict Telegram toggle — small future addition if it proves wanted + . + +## Convergence + +No open design contradictions. The advisory-only contract, single-source recording, +session-level actor resolution, and intent-distinctness are settled and test-locked. +Converged. diff --git a/site/src/content/docs/architecture/under-the-hood.md b/site/src/content/docs/architecture/under-the-hood.md index cf8b2d3c0..05f8f5b24 100644 --- a/site/src/content/docs/architecture/under-the-hood.md +++ b/site/src/content/docs/architecture/under-the-hood.md @@ -58,6 +58,9 @@ The proactive eye. Polls every 60 seconds to classify each session as healthy, i **How they connect:** SessionMonitor detects the problem → SessionRecovery tries a fast fix → if that doesn't work, TriageOrchestrator runs heuristics → if those don't match, it spawns an LLM diagnosis. Meanwhile, SessionWatchdog independently catches stuck commands at the process level. +### CrossSessionCoordinator +A light, advisory signal for when more than one session runs against the same agent home at once — they otherwise share `.instar/` state while blind to each other. CrossSessionCoordinator keeps a shared, append-only scratchpad of recent high-impact actions (feature-flag flips, commitment withdrawals) plus voluntary "I'm about to do X" intents. When a session takes a structural action, it surfaces any recent action by a *different* session as an advisory `coordinationWarning` ("⚠ another session withdrew 19 commitments 4m ago — confirm first"). It never blocks and never mutates the target state — it's a heads-up, not a lock. Default-on, near-silent (no Telegram); audit trail at `logs/cross-session-events.jsonl`. + --- diff --git a/site/src/content/docs/reference/api.md b/site/src/content/docs/reference/api.md index af4be939a..f37d343e0 100644 --- a/site/src/content/docs/reference/api.md +++ b/site/src/content/docs/reference/api.md @@ -123,6 +123,17 @@ The Instar server exposes a REST API on `localhost:4040` (configurable). All end | GET | `/messages/outbox` | Inter-agent outbox | | GET | `/messages/dead-letter` | Dead letter queue | +## Cross-Session Coordination + +Backed by the `CrossSessionCoordinator` — a light, advisory signal so concurrent sessions on one agent home see each other's recent high-impact actions before acting. Never blocks; surfaces a `coordinationWarning`. Pass `X-Instar-Session: ` so the signal can tell sessions apart. + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/coordination/intent` | Announce "I'm about to do X" so other sessions see it before acting | +| GET | `/coordination/recent` | Recent structural actions + intents (newest first) | + +Sensitive `PATCH /config` flips and `POST /commitments/:id/withdraw` calls auto-record and attach a `coordinationWarning` to their own response when another session was recently active. Config: `monitoring.crossSessionCoordination`. Audit: `logs/cross-session-events.jsonl`. + ## Threadline (MCP Tools) These tools are registered as an MCP server and called by Claude Code (or any MCP client) via stdio transport. They are registered automatically on server boot. diff --git a/src/config/ConfigDefaults.ts b/src/config/ConfigDefaults.ts index cc21e2286..e23d29df5 100644 --- a/src/config/ConfigDefaults.ts +++ b/src/config/ConfigDefaults.ts @@ -56,6 +56,21 @@ const SHARED_DEFAULTS: Record = { contextWedgeSentinel: { enabled: true, }, + // CrossSessionCoordinator — light, advisory cross-session coordination signal. + // Default-ON housekeeping (like the silently-stopped sentinels): it records + // high-impact structural actions + voluntary "I'm about to do X" intents and + // surfaces concurrent activity by another session as an in-response advisory + // warning. It NEVER blocks and never mutates target state. enabled:false => + // passive (GET stays 200; nothing recorded, no warnings). No Telegram + // escalation in v1 (near-silent by design). sensitiveConfigKeys are the + // top-level prefixes whose PATCH /config flips get auto-recorded. + // See docs/specs/cross-session-coordination.md. + crossSessionCoordination: { + enabled: true, + windowMs: 600000, + retentionMs: 3600000, + sensitiveConfigKeys: ['monitoring', 'tunnel', 'autonomousSessions', 'lifeline', 'updates'], + }, // SessionReaper — pressure-aware reaper of idle-but-alive sessions. // UNLIKE the sentinels above, default OFF + dry-run: it is the only monitor // that *kills* sessions on a heuristic, so it ships dark and must be flipped diff --git a/src/core/PostUpdateMigrator.ts b/src/core/PostUpdateMigrator.ts index ae4df1ab2..ed5f0612c 100644 --- a/src/core/PostUpdateMigrator.ts +++ b/src/core/PostUpdateMigrator.ts @@ -2605,6 +2605,31 @@ Where to look (never guess mesh state — read it): result.upgraded.push('CLAUDE.md: added Cross-Machine Seamlessness section'); } + // Cross-Session Coordination (light advisory signal) — Agent Awareness + + // Migration Parity. Existing agents need to know that concurrent sessions on + // one home are blind to each other, that high-impact actions should announce + // intent first, and that a coordinationWarning means STOP-and-confirm. This + // is the awareness half of the structural fix — without it the signal exists + // but no session knows to check it. Content-sniffed for idempotency. + // See docs/specs/cross-session-coordination.md. + if (!content.includes('Cross-Session Coordination (light, advisory)')) { + const crossSessionSection = ` +### Cross-Session Coordination (light, advisory) + +More than one session of me can run against this same agent home at once, blind to each other, all writing the same \`.instar/\` state. The damaging case: one session built a fix while another "hit the safety brake" (flipped a feature flag off + mass-withdrew commitments) — neither knew the other was acting, so the bug got fixed but the engine was left off and the test bed wiped. This is a LIGHT, advisory signal (never a lock): a shared scratchpad of recent high-impact actions + voluntary "I'm about to do X" intents. It NEVER blocks — it surfaces a \`coordinationWarning\` so I pause and reconsider. + +- **BEFORE any high-impact structural action** (flipping a feature flag, withdrawing/mass-withdrawing commitments, deleting shared state), announce intent first: \`curl -X POST -H "Authorization: Bearer $AUTH" -H "X-Instar-Session: TOPIC_OR_LABEL" http://localhost:${port}/coordination/intent -H 'Content-Type: application/json' -d '{"activity":"flipping collaborationRedrive off — flood mitigation","area":"monitoring"}'\`. If the response \`coordinationWarning\` is non-null, ANOTHER session recently acted — confirm with the user before proceeding. +- Inspect the ledger directly: \`curl -H "Authorization: Bearer $AUTH" http://localhost:${port}/coordination/recent\` — recent structural actions + intents, newest first. +- Backstop (no reliance on me remembering): \`PATCH /config\` flips of sensitive keys and \`POST /commitments/:id/withdraw\` auto-record, and their responses carry a \`coordinationWarning\` when another session was recently active. +- Pass \`X-Instar-Session: \` on these calls so the signal can tell sessions apart (without it, every action reads as "unknown session" and errs toward warning). +- Default-ON, near-silent (no Telegram). Audit trail: \`logs/cross-session-events.jsonl\`. Config: \`monitoring.crossSessionCoordination\` (enabled/windowMs/retentionMs/sensitiveConfigKeys). +- Proactive: I see a \`coordinationWarning\` → STOP, tell the user another session of me may be acting, confirm before the durable action. User asks "is another session running?" / "what have my sessions been doing?" → GET /coordination/recent. +`; + content += '\n' + crossSessionSection; + patched = true; + result.upgraded.push('CLAUDE.md: added Cross-Session Coordination (light advisory) section'); + } + // CMT-519 — Threadline hub topic + "open this"/bind guidance. Existing agents // need to know threadline notices route parent-or-hub (never per-event topics) // and that "open this" / "tie this to X" in the hub means calling the bind diff --git a/src/data/builtin-manifest.json b/src/data/builtin-manifest.json index 44cb05ce8..6f33b5c89 100644 --- a/src/data/builtin-manifest.json +++ b/src/data/builtin-manifest.json @@ -1,8 +1,8 @@ { "$schema": "./builtin-manifest.schema.json", "schemaVersion": 1, - "generatedAt": "2026-05-28T08:18:59.110Z", - "instarVersion": "1.3.56", + "generatedAt": "2026-05-29T01:49:38.265Z", + "instarVersion": "1.3.73", "entryCount": 193, "entries": { "hook:session-start": { @@ -11,7 +11,7 @@ "domain": "identity", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/session-start.sh", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:dangerous-command-guard": { @@ -20,7 +20,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/dangerous-command-guard.sh", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:grounding-before-messaging": { @@ -29,7 +29,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/grounding-before-messaging.sh", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:compaction-recovery": { @@ -38,7 +38,7 @@ "domain": "identity", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/compaction-recovery.sh", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:external-operation-gate": { @@ -47,7 +47,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/external-operation-gate.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:deferral-detector": { @@ -56,7 +56,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/deferral-detector.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:post-action-reflection": { @@ -65,7 +65,7 @@ "domain": "evolution", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/post-action-reflection.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:external-communication-guard": { @@ -74,7 +74,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/external-communication-guard.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:scope-coherence-collector": { @@ -83,7 +83,7 @@ "domain": "coherence", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/scope-coherence-collector.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:scope-coherence-checkpoint": { @@ -92,7 +92,7 @@ "domain": "coherence", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/scope-coherence-checkpoint.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:free-text-guard": { @@ -101,7 +101,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/free-text-guard.sh", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:claim-intercept": { @@ -110,7 +110,7 @@ "domain": "coherence", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/claim-intercept.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:claim-intercept-response": { @@ -119,7 +119,7 @@ "domain": "coherence", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/claim-intercept-response.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:stop-gate-router": { @@ -128,7 +128,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/stop-gate-router.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "hook:auto-approve-permissions": { @@ -137,7 +137,7 @@ "domain": "safety", "sourcePath": "src/core/PostUpdateMigrator.ts", "installedPath": ".instar/hooks/instar/auto-approve-permissions.js", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "job:health-check": { @@ -409,7 +409,7 @@ "type": "route-group", "domain": "monitoring", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:agents": { @@ -417,7 +417,7 @@ "type": "route-group", "domain": "sessions", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:backups": { @@ -425,7 +425,7 @@ "type": "route-group", "domain": "operations", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:git": { @@ -433,7 +433,7 @@ "type": "route-group", "domain": "coordination", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:memory": { @@ -441,7 +441,7 @@ "type": "route-group", "domain": "memory", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:semantic": { @@ -449,7 +449,7 @@ "type": "route-group", "domain": "memory", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:status": { @@ -457,7 +457,7 @@ "type": "route-group", "domain": "monitoring", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:capabilities": { @@ -465,7 +465,7 @@ "type": "route-group", "domain": "mapping", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:project-map": { @@ -473,7 +473,7 @@ "type": "route-group", "domain": "mapping", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:coherence": { @@ -481,7 +481,7 @@ "type": "route-group", "domain": "coherence", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:topic-bindings": { @@ -489,7 +489,7 @@ "type": "route-group", "domain": "sessions", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:context": { @@ -497,7 +497,7 @@ "type": "route-group", "domain": "context", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:scope-coherence": { @@ -505,7 +505,7 @@ "type": "route-group", "domain": "coherence", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:canonical-state": { @@ -513,7 +513,7 @@ "type": "route-group", "domain": "state", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:ci": { @@ -521,7 +521,7 @@ "type": "route-group", "domain": "monitoring", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:sessions": { @@ -529,7 +529,7 @@ "type": "route-group", "domain": "sessions", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:jobs": { @@ -537,7 +537,7 @@ "type": "route-group", "domain": "scheduling", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:skip-ledger": { @@ -545,7 +545,7 @@ "type": "route-group", "domain": "scheduling", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:telegram": { @@ -553,7 +553,7 @@ "type": "route-group", "domain": "communication", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:attention": { @@ -561,7 +561,7 @@ "type": "route-group", "domain": "communication", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:relationships": { @@ -569,7 +569,7 @@ "type": "route-group", "domain": "relationships", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:feedback": { @@ -577,7 +577,7 @@ "type": "route-group", "domain": "feedback", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:updates": { @@ -585,7 +585,7 @@ "type": "route-group", "domain": "updates", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:dispatches": { @@ -593,7 +593,7 @@ "type": "route-group", "domain": "dispatches", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:quota": { @@ -601,7 +601,7 @@ "type": "route-group", "domain": "monitoring", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:publishing": { @@ -609,7 +609,7 @@ "type": "route-group", "domain": "publishing", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:private-views": { @@ -617,7 +617,7 @@ "type": "route-group", "domain": "publishing", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:tunnel": { @@ -625,7 +625,7 @@ "type": "route-group", "domain": "networking", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:events": { @@ -633,7 +633,7 @@ "type": "route-group", "domain": "networking", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:evolution": { @@ -641,7 +641,7 @@ "type": "route-group", "domain": "evolution", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:watchdog": { @@ -649,7 +649,7 @@ "type": "route-group", "domain": "monitoring", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:topic-memory": { @@ -657,7 +657,7 @@ "type": "route-group", "domain": "memory", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:state-sync": { @@ -665,7 +665,7 @@ "type": "route-group", "domain": "coordination", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:intent": { @@ -673,7 +673,7 @@ "type": "route-group", "domain": "intent", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:triage": { @@ -681,7 +681,7 @@ "type": "route-group", "domain": "safety", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:operations": { @@ -689,7 +689,7 @@ "type": "route-group", "domain": "safety", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:sentinel": { @@ -697,7 +697,7 @@ "type": "route-group", "domain": "safety", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:trust": { @@ -705,7 +705,7 @@ "type": "route-group", "domain": "safety", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:monitoring": { @@ -713,7 +713,7 @@ "type": "route-group", "domain": "monitoring", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:commitments": { @@ -721,7 +721,7 @@ "type": "route-group", "domain": "commitments", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:episodes": { @@ -729,7 +729,7 @@ "type": "route-group", "domain": "memory", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:messages": { @@ -737,7 +737,7 @@ "type": "route-group", "domain": "coordination", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:system-reviews": { @@ -745,7 +745,7 @@ "type": "route-group", "domain": "monitoring", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "route-group:machine-mesh": { @@ -761,7 +761,7 @@ "type": "route-group", "domain": "security", "sourcePath": "src/server/routes.ts", - "contentHash": "83556e7d3550fd59ede03ed0df1ce18ddcbee59e0eb5649f9c104536d0aeb0fe", + "contentHash": "4c8c38ddbed91e990fd37fb1f126904070cedee52cc76759c44a5cce0821735b", "since": "2025-01-01" }, "cli:init": { @@ -1457,7 +1457,7 @@ "type": "subsystem", "domain": "server", "sourcePath": "src/server/AgentServer.ts", - "contentHash": "3c346b47c843bc5bd1d3c04a14d384727fd4669d1e83a83dc958ae18d5dccbc3", + "contentHash": "fb245768cecd07413f84d7c8d3299382adafa1f000114e6e47ff927982a3e54f", "since": "2025-01-01" }, "subsystem:session-manager": { @@ -1465,7 +1465,7 @@ "type": "subsystem", "domain": "sessions", "sourcePath": "src/core/SessionManager.ts", - "contentHash": "a689041288c39070fd0d13362193bd4335367cb5a1db5458e3f73a6295b6e4c7", + "contentHash": "abb9f617bbb0568510470625c390bee45604b421cf48f6fda263c5e8c311f5d9", "since": "2025-01-01" }, "subsystem:auto-updater": { @@ -1489,7 +1489,7 @@ "type": "subsystem", "domain": "updates", "sourcePath": "src/core/PostUpdateMigrator.ts", - "contentHash": "f30e12b40a5234a4f4d8c64bbfcec20c4aa04b52cbd1a17069fc24de84bbef96", + "contentHash": "245ea0c8c5929babcf12b894c5e14e8d9e2ff4231ef2f563356076b7961fdf80", "since": "2025-01-01" }, "subsystem:scheduler": { @@ -1497,7 +1497,7 @@ "type": "subsystem", "domain": "scheduling", "sourcePath": "src/scheduler/JobScheduler.ts", - "contentHash": "91e02d0321467469a91ff42df2c5138ef28e21273ae85d82d57b20c4a73cec92", + "contentHash": "f490bc91cd9bac62329fd66152225d8f90549cb167909ceea349d1e3a5323b96", "since": "2025-01-01" }, "subsystem:project-mapper": { @@ -1529,7 +1529,7 @@ "type": "subsystem", "domain": "monitoring", "sourcePath": "src/monitoring/OrphanProcessReaper.ts", - "contentHash": "5d1a3a2b01fef05b741a0e527130026c8c565ee73ba60e97f0d3bcc5abe179c1", + "contentHash": "40855b07f6b43a41c23e35b1b476582a3a1b00190b726befb1a184d32245b7ae", "since": "2025-01-01" }, "subsystem:semantic-memory": { diff --git a/src/monitoring/CrossSessionCoordinator.ts b/src/monitoring/CrossSessionCoordinator.ts new file mode 100644 index 000000000..bf76a0fbc --- /dev/null +++ b/src/monitoring/CrossSessionCoordinator.ts @@ -0,0 +1,287 @@ +/** + * CrossSessionCoordinator — light, advisory cross-session coordination signal. + * + * A single agent home can run MULTIPLE concurrent Claude Code sessions against + * the same `.instar/` state. They are blind to each other. The damaging failure + * (2026-05-28): one session built a fix while a second session "hit the safety + * brake" — flipped a config flag and mass-withdrew ~19 commitments. Both reached + * a correct local diagnosis; neither knew the other was acting durably. + * + * This is the LIGHT fix Justin approved (topic 15579): a shared, append-only + * scratchpad of recent high-impact "structural actions" + voluntary "I'm about + * to do X" intents. Any structural action surfaces *other recent* entries to the + * actor as an advisory warning. It NEVER blocks and it NEVER mutates the target + * state — it only records and surfaces. Spec: docs/specs/cross-session-coordination.md. + * + * Storage: `/state/cross-session-actions.json` (atomic temp+rename, + * reload-per-op so concurrent server-process writes don't clobber). Audit: + * `/logs/cross-session-events.jsonl`. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** The kinds of action the coordinator tracks. */ +export type CoordinationActionKind = + | 'intent' // voluntary "I'm about to do X" announcement + | 'config-flag' // a config-flag flip (auto-recorded at PATCH /config) + | 'commitment-withdraw' // a commitment withdrawal (auto-recorded via event) + | 'other'; // escape hatch for callers + +export interface CoordinationAction { + /** Stable id (`--`). */ + id: string; + kind: CoordinationActionKind; + /** What was acted on — e.g. `monitoring.collaborationRedrive.enabled`, a commitment id, or a free-text area for intents. */ + target?: string; + /** Optional value (e.g. the new flag value). */ + value?: unknown; + /** Human reason / description ("building PR 495 fix for the redrive flood"). */ + reason?: string; + /** Best-effort actor hint (topic id, session label). Often unknown — that's fine. */ + actor?: string; + /** epoch ms. */ + ts: number; +} + +export interface RecordInput { + kind: CoordinationActionKind; + target?: string; + value?: unknown; + reason?: string; + actor?: string; +} + +export interface RecordResult { + recorded: boolean; + id: string | null; + /** Recent actions (within windowMs) by a DIFFERENT or UNKNOWN actor — the "another session may be active" signal. */ + concurrent: CoordinationAction[]; + /** Advisory, human-readable warning built from `concurrent`, or null when clear. */ + warning: string | null; +} + +interface Store { + version: number; + actions: CoordinationAction[]; +} + +export interface CrossSessionCoordinatorOptions { + stateDir: string; + /** When false the coordinator records nothing and never warns (GET still works). Default true. */ + enabled?: boolean; + /** Concurrency window — other actions newer than this count as "concurrent". Default 10 min. */ + windowMs?: number; + /** Ledger retention — actions older than this are pruned on write. Default 60 min. */ + retentionMs?: number; + /** Hard cap on stored actions (newest kept). Default 200. */ + maxActions?: number; + /** Injectable clock for tests. */ + now?: () => number; +} + +const DEFAULT_WINDOW_MS = 10 * 60 * 1000; +const DEFAULT_RETENTION_MS = 60 * 60 * 1000; +const DEFAULT_MAX_ACTIONS = 200; + +export class CrossSessionCoordinator { + private readonly storePath: string; + private readonly auditPath: string; + private readonly enabled: boolean; + private readonly windowMs: number; + private readonly retentionMs: number; + private readonly maxActions: number; + private readonly now: () => number; + private seq = 0; + + constructor(opts: CrossSessionCoordinatorOptions) { + const stateRoot = path.join(opts.stateDir, 'state'); + this.storePath = path.join(stateRoot, 'cross-session-actions.json'); + this.auditPath = path.join(opts.stateDir, 'logs', 'cross-session-events.jsonl'); + this.enabled = opts.enabled !== false; + this.windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS; + this.retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS; + this.maxActions = opts.maxActions ?? DEFAULT_MAX_ACTIONS; + this.now = opts.now ?? Date.now; + } + + isEnabled(): boolean { + return this.enabled; + } + + /** + * Record an action and compute the advisory signal. Never throws — coordination + * is advisory, so a persistence failure must not break the calling route. + */ + record(input: RecordInput): RecordResult { + if (!this.enabled) { + return { recorded: false, id: null, concurrent: [], warning: null }; + } + const now = this.now(); + const rec: CoordinationAction = { + id: `${input.kind}-${now}-${this.seq++}`, + kind: input.kind, + target: input.target, + value: input.value, + reason: input.reason, + actor: input.actor, + ts: now, + }; + + let concurrent: CoordinationAction[] = []; + try { + const store = this.load(); + // Prune expired before evaluating + appending. + store.actions = store.actions.filter((a) => now - a.ts <= this.retentionMs); + // Concurrency = other recent actions by a DIFFERENT or UNKNOWN actor, not the literal same action. + concurrent = store.actions.filter( + (a) => + now - a.ts <= this.windowMs && + differentOrUnknownActor(a.actor, rec.actor) && + !sameAction(a, rec), + ); + store.actions.push(rec); + if (store.actions.length > this.maxActions) { + store.actions = store.actions.slice(-this.maxActions); + } + store.version = (store.version ?? 0) + 1; + this.save(store); + this.audit(rec, concurrent.length); + } catch { + // @silent-fallback-ok — coordination is ADVISORY by contract: a persistence + // failure must never break the calling route. We still return the computed + // signal; a dropped ledger write only weakens a future advisory, never the + // primary action. + } + + return { + recorded: true, + id: rec.id, + concurrent, + warning: this.buildWarning(concurrent, rec), + }; + } + + /** Voluntary "I'm about to do X" announcement. */ + recordIntent(activity: string, opts?: { actor?: string; area?: string }): RecordResult { + return this.record({ kind: 'intent', reason: activity, target: opts?.area, actor: opts?.actor }); + } + + /** Recent actions for the GET endpoint / explicit pre-action checks (newest first). */ + getRecent(opts?: { windowMs?: number; limit?: number }): CoordinationAction[] { + const now = this.now(); + const window = opts?.windowMs ?? this.retentionMs; + let actions: CoordinationAction[]; + try { + actions = this.load().actions; + } catch { + // @silent-fallback-ok — read-only inspection path; if the ledger can't be + // read there is simply nothing recent to show. Advisory, never load-bearing. + return []; + } + return actions + .filter((a) => now - a.ts <= window) + .sort((a, b) => b.ts - a.ts) + .slice(0, opts?.limit ?? 100); + } + + // ── internals ────────────────────────────────────────────────────────── + + private load(): Store { + try { + const raw = fs.readFileSync(this.storePath, 'utf8'); + const parsed = JSON.parse(raw) as Store; + if (!parsed || !Array.isArray(parsed.actions)) return { version: 0, actions: [] }; + return parsed; + } catch { + // @silent-fallback-ok — a missing/corrupt ledger file is the expected + // first-run state, not a degradation: an empty ledger is the correct read. + return { version: 0, actions: [] }; + } + } + + private save(store: Store): void { + fs.mkdirSync(path.dirname(this.storePath), { recursive: true }); + const tmp = `${this.storePath}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(store, null, 2)); + fs.renameSync(tmp, this.storePath); + } + + private audit(rec: CoordinationAction, concurrentCount: number): void { + try { + fs.mkdirSync(path.dirname(this.auditPath), { recursive: true }); + fs.appendFileSync( + this.auditPath, + JSON.stringify({ ...rec, concurrentCount, recordedAt: new Date(rec.ts).toISOString() }) + '\n', + ); + } catch { + // @silent-fallback-ok — the JSONL audit trail is observability, not control + // flow; a failed append must not affect the recorded action or the response. + } + } + + private buildWarning(concurrent: CoordinationAction[], rec: CoordinationAction): string | null { + if (concurrent.length === 0) return null; + const now = rec.ts; + const windowMin = Math.round(this.windowMs / 60000); + const list = concurrent + .slice(-3) + .reverse() + .map((a) => { + const agoMin = Math.max(0, Math.round((now - a.ts) / 60000)); + const who = a.actor ? `actor ${a.actor}` : 'an unattributed session'; + const what = describeAction(a); + return `${what} by ${who} ${agoMin}m ago`; + }) + .join('; '); + const mine = describeAction(rec); + return ( + `⚠ Cross-session: ${concurrent.length} recent structural action(s) by another/unknown session ` + + `in the last ${windowMin}m, alongside your ${mine}: ${list}. ` + + `Another session may be active — confirm this is intended before proceeding ` + + `(GET /coordination/recent for the full ledger).` + ); + } +} + +function differentOrUnknownActor(a: string | undefined, b: string | undefined): boolean { + // Same known actor → not concurrent (don't warn a session about its own prior actions). + // Any unknown on either side → treat as potentially different (include). + if (a && b) return a !== b; + return true; +} + +function sameAction(a: CoordinationAction, b: CoordinationAction): boolean { + // Intents are events ("I'm about to do X"), not states — every announcement is + // distinct, so they are NEVER deduped (two sessions each announcing is exactly + // the signal we want to surface). Dedupe applies only to state-flip kinds + // (config-flag / commitment-withdraw / other), where two sessions writing the + // identical kind+target+value really are the same write and shouldn't double-count. + if (a.kind === 'intent' || b.kind === 'intent') return false; + return a.kind === b.kind && a.target === b.target && stringifyValue(a.value) === stringifyValue(b.value); +} + +function stringifyValue(v: unknown): string { + try { + return JSON.stringify(v ?? null); + } catch { + return String(v); + } +} + +function describeAction(a: CoordinationAction): string { + switch (a.kind) { + case 'intent': + return `intent${a.reason ? ` "${truncate(a.reason, 80)}"` : ''}`; + case 'config-flag': + return `config flip ${a.target ?? '(unknown key)'}${a.value !== undefined ? `=${stringifyValue(a.value)}` : ''}`; + case 'commitment-withdraw': + return `commitment withdrawal${a.target ? ` (${a.target})` : ''}`; + default: + return `action ${a.kind}${a.target ? ` on ${a.target}` : ''}`; + } +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} diff --git a/src/scaffold/templates.ts b/src/scaffold/templates.ts index 90ac23033..6b6854aa3 100644 --- a/src/scaffold/templates.ts +++ b/src/scaffold/templates.ts @@ -422,6 +422,14 @@ This routes feedback to the Instar maintainers automatically. Valid types: \`bug - Stop every job: \`curl -X POST -H "Authorization: Bearer $AUTH" http://localhost:${port}/autonomous/stop-all\` - Proactive: user asks "what autonomous jobs are running?" → GET /autonomous/sessions. "stop everything" → POST /autonomous/stop-all. "stop the job on topic X" → POST /autonomous/sessions/X/stop. +**Cross-Session Coordination (light, advisory)** — More than one session of me can run against this same agent home at once, blind to each other, all writing the same \`.instar/\` state. The damaging case: one session built a fix while another "hit the safety brake" (flipped a feature flag off + mass-withdrew commitments) — neither knew the other was acting. This is a LIGHT, advisory signal (never a lock): a shared scratchpad of recent high-impact actions + voluntary "I'm about to do X" intents. It NEVER blocks — it just surfaces a \`coordinationWarning\` so I pause and reconsider. +- **BEFORE any high-impact structural action** (flipping a feature flag, withdrawing/mass-withdrawing commitments, deleting shared state), announce intent first: \`curl -X POST -H "Authorization: Bearer $AUTH" -H "X-Instar-Session: TOPIC_OR_LABEL" http://localhost:${port}/coordination/intent -H 'Content-Type: application/json' -d '{"activity":"flipping collaborationRedrive off — flood mitigation","area":"monitoring"}'\`. If the response \`coordinationWarning\` is non-null, ANOTHER session recently acted — confirm with the user before proceeding. +- Inspect the ledger directly: \`curl -H "Authorization: Bearer $AUTH" http://localhost:${port}/coordination/recent\` — recent structural actions + intents, newest first. +- Backstop (no reliance on me remembering): \`PATCH /config\` flips of sensitive keys and \`POST /commitments/:id/withdraw\` auto-record, and their responses carry a \`coordinationWarning\` when another session was recently active. +- Pass \`X-Instar-Session: \` on these calls so the signal can tell sessions apart (without it, every action reads as "unknown session" and errs toward warning). +- Default-ON, near-silent (no Telegram). Audit trail: \`logs/cross-session-events.jsonl\`. Config: \`monitoring.crossSessionCoordination\` (enabled/windowMs/retentionMs/sensitiveConfigKeys). See docs/specs/cross-session-coordination.md. +- Proactive: I see a \`coordinationWarning\` → STOP, tell the user another session of me may be acting, confirm before the durable action. User asks "is another session running?" / "what have my sessions been doing?" → GET /coordination/recent. + **Cross-Machine Seamlessness (one agent, many machines)** — When I run on more than one machine, I am ONE agent that follows the user across them, not clones. Exactly one machine is "awake" at a time, decided by a **fenced lease** (a clock-proof, numbered "who's in charge" badge); the other is standby and takes over only when the awake machine genuinely goes silent. - **I never double-reply** — each inbound message is handled exactly once (durable per-message ledger keyed on the platform event id), so a redelivery or mid-handoff overlap can't make me answer twice. - **A handoff feels like a compaction pause, not amnesia** — the new machine resumes via CONTINUATION (picks up the thread, no re-greeting). Planned handoff = current context; hard failover = as-of-last-sync, and if my context is partial I say so honestly ("picking this back up from the other machine"). diff --git a/src/server/AgentServer.ts b/src/server/AgentServer.ts index 1e943c5b0..4216d1f5a 100644 --- a/src/server/AgentServer.ts +++ b/src/server/AgentServer.ts @@ -63,6 +63,7 @@ import os from 'node:os'; import { TokenLedger } from '../monitoring/TokenLedger.js'; import { TokenLedgerPoller } from '../monitoring/TokenLedgerPoller.js'; import { FrameworkIssueLedger } from '../monitoring/FrameworkIssueLedger.js'; +import { CrossSessionCoordinator } from '../monitoring/CrossSessionCoordinator.js'; import { MentorOnboardingRunner, DEFAULT_MENTOR_CONFIG, type MentorConfig } from '../scheduler/MentorOnboardingRunner.js'; import { STAGE_A_ALLOWED_TOOLS } from '../monitoring/MentorStageA.js'; import { analyzeForensics } from '../scheduler/MentorStageBForensics.js'; @@ -101,6 +102,7 @@ export class AgentServer { private tokenLedger: TokenLedger | null = null; private tokenLedgerPoller: TokenLedgerPoller | null = null; private frameworkIssueLedger: FrameworkIssueLedger | null = null; + private crossSessionCoordinator: CrossSessionCoordinator | null = null; private mentorRunner: MentorOnboardingRunner | null = null; /** Wall-clock of the last mentor tick that ran, for the min-interval floor. */ private mentorLastTickAt = 0; @@ -567,6 +569,30 @@ export class AgentServer { } } + // CrossSessionCoordinator — light, advisory cross-session coordination signal + // (docs/specs/cross-session-coordination.md). Records high-impact structural + // actions + voluntary intents so concurrent sessions on one agent home see + // each other before acting. Never blocks, never mutates target state. Always + // constructed (read routes stay alive); records/warns only when enabled. Own + // try/catch so it can never cascade into the other monitors' init. + if (options.config.stateDir) { + try { + const xcfg = + ((options.config as unknown as Record).monitoring as Record | undefined) + ?.crossSessionCoordination as Record | undefined; + this.crossSessionCoordinator = new CrossSessionCoordinator({ + stateDir: options.config.stateDir, + enabled: xcfg?.enabled !== false, + windowMs: typeof xcfg?.windowMs === 'number' ? xcfg.windowMs : undefined, + retentionMs: typeof xcfg?.retentionMs === 'number' ? xcfg.retentionMs : undefined, + maxActions: typeof xcfg?.maxActions === 'number' ? xcfg.maxActions : undefined, + }); + } catch (err) { + console.warn('[instar] cross-session-coordinator init failed (non-fatal):', err); + this.crossSessionCoordinator = null; + } + } + // Failure-Learning Loop (docs/specs/FAILURE-LEARNING-LOOP-SPEC.md) — instar // self-hosting dev-process forensics. Ships OFF; constructed only when // enabled (else the inline /failures routes 503-stub via the null ledger). @@ -746,6 +772,7 @@ export class AgentServer { machineHeartbeat: options.machineHeartbeat ?? null, tokenLedger: this.tokenLedger, frameworkIssueLedger: this.frameworkIssueLedger, + crossSessionCoordinator: this.crossSessionCoordinator, mentorRunner: this.mentorRunner, failureLedger: this.failureLedger, failureAttributionEngine: this.failureAttributionEngine, diff --git a/src/server/CapabilityIndex.ts b/src/server/CapabilityIndex.ts index ccecad56d..b45f54408 100644 --- a/src/server/CapabilityIndex.ts +++ b/src/server/CapabilityIndex.ts @@ -69,6 +69,19 @@ export const CAPABILITY_INDEX: readonly CapabilityEntry[] = [ ], }), }, + { + key: 'crossSessionCoordination', + prefixes: ['/coordination'], + description: 'Cross-session coordination — light, advisory signal so concurrent sessions on one agent home see each other before high-impact actions (config flips, commitment withdrawals). Never blocks; surfaces a coordinationWarning. See docs/specs/cross-session-coordination.md.', + build: ({ ctx }) => ({ + configured: !!ctx.crossSessionCoordinator, + enabled: !!ctx.crossSessionCoordinator?.isEnabled(), + endpoints: [ + 'POST /coordination/intent — announce "I\'m about to do X" so other sessions see it', + 'GET /coordination/recent — recent structural actions + intents (newest first)', + ], + }), + }, { key: 'telegram', prefixes: ['/telegram'], diff --git a/src/server/routes.ts b/src/server/routes.ts index 3b60b08e7..6bac4d8fb 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -547,6 +547,11 @@ export interface RouteContext { orphanReaper: OrphanProcessReaper | null; coherenceMonitor: CoherenceMonitor | null; commitmentTracker: CommitmentTracker | null; + /** CrossSessionCoordinator — light, advisory cross-session coordination signal. + * Distinct from the multi-MACHINE `coordinator` below. Always constructed + * inside AgentServer (read routes stay alive), so rarely null. See + * docs/specs/cross-session-coordination.md. */ + crossSessionCoordinator: import('../monitoring/CrossSessionCoordinator.js').CrossSessionCoordinator | null; semanticMemory: SemanticMemory | null; activitySentinel: SessionActivitySentinel | null; rateLimitSentinel: import('../monitoring/RateLimitSentinel.js').RateLimitSentinel | null; @@ -915,6 +920,57 @@ export const PATCHABLE_CONFIG_KEYS: ReadonlySet = new Set([ 'dispatches', 'feedback', ]); +// ── Cross-session coordination helpers (see docs/specs/cross-session-coordination.md) ── + +/** + * Top-level config prefixes whose flips are "structural" enough to auto-record on + * the cross-session ledger (feature on/off toggles, tunnel, autonomy, lifeline, + * updates). A flip to `monitoring.x.enabled` matches the `monitoring` prefix. + * Overridable per-agent via `monitoring.crossSessionCoordination.sensitiveConfigKeys`. + */ +const DEFAULT_SENSITIVE_CONFIG_KEYS: readonly string[] = [ + 'monitoring', 'tunnel', 'autonomousSessions', 'lifeline', 'updates', +]; + +/** + * Flatten a config patch into `[dottedPath, leafValue]` pairs. Objects recurse; + * arrays + primitives are leaves. Used to record which sensitive keys a + * `PATCH /config` flipped. + */ +function* flattenConfigFlips(obj: unknown, prefix = ''): Generator<[string, unknown]> { + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { + if (prefix) yield [prefix, obj]; + return; + } + for (const [k, v] of Object.entries(obj as Record)) { + const p = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + yield* flattenConfigFlips(v, p); + } else { + yield [p, v]; + } + } +} + +/** + * Best-effort actor hint for the cross-session ledger. Resolves a SESSION-level + * discriminator only — never the agent id, because every session of one agent + * shares it and using it would suppress the cross-session warning we want. When + * nothing identifies the session, returns undefined (treated as "potentially a + * different session" → errs toward surfacing the signal). + */ +function coordinationActor(req: { headers: Record; body?: unknown }): string | undefined { + const h = (k: string): string | undefined => { + const v = req.headers[k]; + return typeof v === 'string' && v.trim() ? v.trim() : undefined; + }; + const bodyActor = + req.body && typeof req.body === 'object' && typeof (req.body as Record).actor === 'string' + ? ((req.body as Record).actor as string).trim() || undefined + : undefined; + return h('x-instar-session') || h('x-instar-actor') || bodyActor; +} + export function createRoutes(ctx: RouteContext): Router { const router = Router(); @@ -11906,10 +11962,26 @@ export function createRoutes(ctx: RouteContext): Router { fs.writeFileSync(configPath, JSON.stringify(fileConfig, null, 2) + '\n'); + // Cross-session coordination (advisory, non-blocking): record sensitive-key + // flips and surface any concurrent activity by another session. This is the + // backstop that catches the "one session flipped the engine flag while + // another was building a fix" failure. See cross-session-coordination.md. + let coordinationWarning: string | null = null; + if (ctx.crossSessionCoordinator?.isEnabled()) { + const sensitive = (ctx.config as any)?.monitoring?.crossSessionCoordination?.sensitiveConfigKeys; + const sensitiveKeys: readonly string[] = Array.isArray(sensitive) ? sensitive : DEFAULT_SENSITIVE_CONFIG_KEYS; + for (const [target, value] of flattenConfigFlips(patch)) { + if (!sensitiveKeys.some((k) => target === k || target.startsWith(k + '.'))) continue; + const r = ctx.crossSessionCoordinator.record({ kind: 'config-flag', target, value, actor: coordinationActor(req) }); + if (r.warning) coordinationWarning = coordinationWarning ? `${coordinationWarning}\n${r.warning}` : r.warning; + } + } + res.json({ success: true, patched: Object.keys(patch), note: 'Some changes may require a server restart to take full effect.', + ...(coordinationWarning ? { coordinationWarning } : {}), }); } catch (err) { res.status(500).json({ error: `Failed to patch config: ${err instanceof Error ? err.message : String(err)}` }); @@ -12394,7 +12466,56 @@ export function createRoutes(ctx: RouteContext): Router { res.status(404).json({ error: `Commitment ${req.params.id} not found or already resolved` }); return; } - res.json({ withdrawn: true, id: req.params.id }); + // Cross-session coordination (advisory): a single withdrawal is benign, but a + // burst across sessions is exactly the "mass-withdrew 19 commitments while + // another session was building" failure. Record it and surface concurrent + // activity to the actor. Never blocks. See cross-session-coordination.md. + let coordinationWarning: string | null = null; + if (ctx.crossSessionCoordinator?.isEnabled()) { + const r = ctx.crossSessionCoordinator.record({ + kind: 'commitment-withdraw', + target: req.params.id, + reason, + actor: coordinationActor(req), + }); + coordinationWarning = r.warning; + } + res.json({ withdrawn: true, id: req.params.id, ...(coordinationWarning ? { coordinationWarning } : {}) }); + }); + + // ── Cross-session coordination (light, advisory) ─────────────────────── + // A shared scratchpad so concurrent sessions on the same agent home see each + // other before taking high-impact actions. Never blocks. See + // docs/specs/cross-session-coordination.md. + + // POST /coordination/intent — announce "I'm about to do X" so other sessions see it. + router.post('/coordination/intent', (req, res) => { + if (!ctx.crossSessionCoordinator) { + res.status(503).json({ error: 'CrossSessionCoordinator not configured' }); + return; + } + const { activity, area } = req.body ?? {}; + if (!activity || typeof activity !== 'string' || activity.length > 2000) { + res.status(400).json({ error: '"activity" must be a non-empty string under 2000 characters' }); + return; + } + const r = ctx.crossSessionCoordinator.recordIntent(activity, { + actor: coordinationActor(req), + area: typeof area === 'string' ? area : undefined, + }); + res.status(201).json({ recorded: r.recorded, id: r.id, concurrent: r.concurrent, coordinationWarning: r.warning }); + }); + + // GET /coordination/recent — the ledger, for an explicit pre-action check / inspection. + router.get('/coordination/recent', (req, res) => { + if (!ctx.crossSessionCoordinator) { + res.status(503).json({ error: 'CrossSessionCoordinator not configured' }); + return; + } + const limit = typeof req.query.limit === 'string' ? Math.max(1, Math.min(500, parseInt(req.query.limit, 10) || 100)) : 100; + const windowMs = typeof req.query.windowMs === 'string' ? Math.max(0, parseInt(req.query.windowMs, 10) || 0) : undefined; + const actions = ctx.crossSessionCoordinator.getRecent({ limit, windowMs }); + res.json({ enabled: ctx.crossSessionCoordinator.isEnabled(), count: actions.length, actions }); }); /** diff --git a/tests/e2e/cross-session-coordination-lifecycle.test.ts b/tests/e2e/cross-session-coordination-lifecycle.test.ts new file mode 100644 index 000000000..a038132e4 --- /dev/null +++ b/tests/e2e/cross-session-coordination-lifecycle.test.ts @@ -0,0 +1,122 @@ +/** + * Tier-3 E2E "feature is alive" lifecycle test for the Cross-Session Coordination + * signal (docs/specs/cross-session-coordination.md). + * + * Per TESTING-INTEGRITY-SPEC: the single most important test for any feature with + * API routes — is it actually alive on the production init path (200, not 503)? + * This boots the REAL AgentServer (the same path server.ts uses) and verifies: + * 1. The CrossSessionCoordinator is instantiated at startup (wiring integrity). + * 2. GET /coordination/recent returns 200, not 503 — and is enabled by default. + * 3. An announced intent surfaces end-to-end through the live HTTP route. + * 4. THE INCIDENT shape: a second session's action carries a coordinationWarning + * naming the first session — surfaced end-to-end through the real server. + * 5. The audit JSONL is written under logs/. + * 6. The capability is discoverable via /capabilities. + * 7. Auth is required, like every non-/health route. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { AgentServer } from '../../src/server/AgentServer.js'; +import { StateManager } from '../../src/core/StateManager.js'; +import type { InstarConfig } from '../../src/core/types.js'; +import { SafeFsExecutor } from '../../src/core/SafeFsExecutor.js'; + +function createMockSessionManager() { + return { listRunningSessions: () => [], getSession: () => null }; +} + +describe('CrossSessionCoordinator E2E lifecycle (feature is alive)', () => { + let tmpDir: string; + let stateDir: string; + let server: AgentServer; + let app: express.Express; + const AUTH = 'test-e2e-cross-session-coordination'; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xsession-e2e-')); + stateDir = path.join(tmpDir, '.instar'); + fs.mkdirSync(path.join(stateDir, 'state', 'sessions'), { recursive: true }); + fs.mkdirSync(path.join(stateDir, 'logs'), { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'config.json'), JSON.stringify({ port: 0, projectName: 'e2e', agentName: 'E2E' })); + + const config: InstarConfig = { + projectName: 'e2e', projectDir: tmpDir, stateDir, port: 0, authToken: AUTH, + requestTimeoutMs: 10000, version: '0.0.0', + sessions: { claudePath: '/usr/bin/echo', maxSessions: 3, defaultMaxDurationMinutes: 30, protectedSessions: [], monitorIntervalMs: 5000 }, + scheduler: { enabled: false, jobsFile: '', maxParallelJobs: 1 }, + // monitoring left empty on purpose — the coordinator must still come up + // enabled (default-on), proving the AgentServer-side default literal works + // even before ConfigDefaults has been applied. + messaging: [], monitoring: {}, updates: {}, + } as InstarConfig; + + server = new AgentServer({ config, sessionManager: createMockSessionManager() as any, state: new StateManager(stateDir) }); + await server.start(); + app = server.getApp(); + }); + + afterAll(async () => { + await server.stop(); + SafeFsExecutor.safeRmSync(tmpDir, { recursive: true, force: true, operation: 'tests/e2e/cross-session-coordination-lifecycle.test.ts' }); + }); + + const auth = () => ({ Authorization: `Bearer ${AUTH}` }); + + it('GET /coordination/recent is alive — returns 200 (not 503) and enabled by default', async () => { + const res = await request(app).get('/coordination/recent').set(auth()); + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); + expect(Array.isArray(res.body.actions)).toBe(true); + }); + + it('an announced intent surfaces end-to-end through the live route', async () => { + const post = await request(app) + .post('/coordination/intent') + .set(auth()) + .set('X-Instar-Session', 'session-A') + .send({ activity: 'building PR 495 fix for the redrive flood', area: 'monitoring' }); + expect(post.status).toBe(201); + expect(post.body.recorded).toBe(true); + + const recent = await request(app).get('/coordination/recent').set(auth()); + expect(recent.body.count).toBeGreaterThanOrEqual(1); + expect(JSON.stringify(recent.body.actions)).toMatch(/building PR 495 fix/); + }); + + it('THE INCIDENT: a second session withdrawing while A is building carries a coordinationWarning', async () => { + // Session B "hits the safety brake" via the withdraw route. Even with no real + // commitment present this exercises the coordination wiring up to the + // tracker; we assert the advisory path independently below via the ledger. + // Here we use a fresh intent pair to prove the warning surfaces live. + await request(app).post('/coordination/intent').set(auth()) + .set('X-Instar-Session', 'builder').send({ activity: 'building the structural fix' }); + const brake = await request(app).post('/coordination/intent').set(auth()) + .set('X-Instar-Session', 'brake').send({ activity: 'flipping the engine off' }); + expect(brake.status).toBe(201); + expect(brake.body.coordinationWarning).toBeTruthy(); + expect(brake.body.coordinationWarning).toMatch(/another\/unknown session/); + }); + + it('writes a JSONL audit trail under logs/', () => { + const auditPath = path.join(stateDir, 'logs', 'cross-session-events.jsonl'); + expect(fs.existsSync(auditPath)).toBe(true); + const lines = fs.readFileSync(auditPath, 'utf8').trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThanOrEqual(1); + expect(JSON.parse(lines[0])).toHaveProperty('recordedAt'); + }); + + it('surfaces the coordination capability in /capabilities (discoverability)', async () => { + const res = await request(app).get('/capabilities').set(auth()); + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).toMatch(/coordination/); + }); + + it('requires auth (Bearer token) like every non-/health route', async () => { + const res = await request(app).get('/coordination/recent'); // no auth header + expect(res.status).toBe(401); + }); +}); diff --git a/tests/integration/cross-session-coordination-routes.test.ts b/tests/integration/cross-session-coordination-routes.test.ts new file mode 100644 index 000000000..ab16d251a --- /dev/null +++ b/tests/integration/cross-session-coordination-routes.test.ts @@ -0,0 +1,189 @@ +/** + * Integration — cross-session coordination routes (light, advisory signal). + * Spec: docs/specs/cross-session-coordination.md. + * + * Mounts the REAL router with a REAL CrossSessionCoordinator and exercises the + * full HTTP surface end-to-end. Reproduces the damaging incident shape: while + * session A has announced it is building a fix, session B's durable actions + * (config-flag flip + commitment withdraw) come back carrying a + * `coordinationWarning` — the visible "another session is acting" signal Justin + * approved. Nothing is ever blocked. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { CrossSessionCoordinator } from '../../src/monitoring/CrossSessionCoordinator.js'; +import { createRoutes } from '../../src/server/routes.js'; +import { SafeFsExecutor } from '../../src/core/SafeFsExecutor.js'; + +interface Server { url: string; close: () => Promise; } +async function listen(app: express.Express): Promise { + return new Promise((resolve) => { + const srv = app.listen(0, () => { + const port = (srv.address() as AddressInfo).port; + resolve({ url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => srv.close(() => r())) }); + }); + }); +} + +describe('cross-session coordination routes', () => { + let tmpDir: string; + let projectDir: string; + let server: Server | undefined; + + function buildApp(opts: { coordinator: CrossSessionCoordinator | null; withdrawOk?: boolean } ): express.Express { + const app = express(); + app.use(express.json()); + const ctx: any = { + crossSessionCoordinator: opts.coordinator, + // Minimal commitmentTracker stub — the withdraw route only needs a truthy + // withdraw(); this test exercises the COORDINATION wiring, not CommitmentTracker. + commitmentTracker: { withdraw: () => opts.withdrawOk !== false }, + config: { authToken: 'test', stateDir: projectDir, projectDir, port: 0 }, + stateDir: projectDir, + }; + app.use(createRoutes(ctx)); + return app; + } + + function makeCoordinator(enabled = true): CrossSessionCoordinator { + return new CrossSessionCoordinator({ stateDir: projectDir, enabled }); + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xsession-routes-')); + projectDir = tmpDir; + fs.mkdirSync(path.join(projectDir, '.instar'), { recursive: true }); + // Seed a config file so PATCH /config has something to merge into. + fs.writeFileSync( + path.join(projectDir, '.instar', 'config.json'), + JSON.stringify({ monitoring: { collaborationRedrive: { enabled: true } } }, null, 2), + ); + }); + + afterEach(async () => { + if (server) { await server.close(); server = undefined; } + SafeFsExecutor.safeRmSync(tmpDir, { recursive: true, force: true, operation: 'tests/integration/cross-session-coordination-routes.test.ts' }); + }); + + it('GET /coordination/recent returns 200 (feature alive, not 503) when wired', async () => { + server = await listen(buildApp({ coordinator: makeCoordinator() })); + const resp = await fetch(`${server.url}/coordination/recent`); + expect(resp.status).toBe(200); + const body = await resp.json(); + expect(body.enabled).toBe(true); + expect(body.count).toBe(0); + expect(Array.isArray(body.actions)).toBe(true); + }); + + it('GET + POST 503 when no coordinator is configured', async () => { + server = await listen(buildApp({ coordinator: null })); + expect((await fetch(`${server.url}/coordination/recent`)).status).toBe(503); + const post = await fetch(`${server.url}/coordination/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ activity: 'x' }), + }); + expect(post.status).toBe(503); + }); + + it('POST /coordination/intent records and is visible via GET /coordination/recent', async () => { + server = await listen(buildApp({ coordinator: makeCoordinator() })); + const post = await fetch(`${server.url}/coordination/intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'session-A' }, + body: JSON.stringify({ activity: 'building PR 495 fix for the redrive flood', area: 'monitoring' }), + }); + expect(post.status).toBe(201); + const body = await post.json(); + expect(body.recorded).toBe(true); + expect(body.id).toMatch(/^intent-/); + expect(body.coordinationWarning).toBeNull(); // first action — nothing concurrent + + const recent = await (await fetch(`${server.url}/coordination/recent`)).json(); + expect(recent.count).toBe(1); + expect(recent.actions[0].reason).toContain('building PR 495 fix'); + expect(recent.actions[0].actor).toBe('session-A'); + }); + + it('rejects an empty / oversized activity (400)', async () => { + server = await listen(buildApp({ coordinator: makeCoordinator() })); + const empty = await fetch(`${server.url}/coordination/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ activity: '' }), + }); + expect(empty.status).toBe(400); + }); + + it('a SECOND session announcing intent sees the first session (coordinationWarning)', async () => { + server = await listen(buildApp({ coordinator: makeCoordinator() })); + await fetch(`${server.url}/coordination/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'session-A' }, + body: JSON.stringify({ activity: 'building PR 495 fix' }), + }); + const bResp = await fetch(`${server.url}/coordination/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'session-B' }, + body: JSON.stringify({ activity: 'hitting the safety brake' }), + }); + const b = await bResp.json(); + expect(b.coordinationWarning).toBeTruthy(); + expect(b.coordinationWarning).toMatch(/another\/unknown session/); + expect(b.concurrent).toHaveLength(1); + expect(b.concurrent[0].actor).toBe('session-A'); + }); + + it('THE INCIDENT: a config-flag flip while another session is building surfaces a warning + writes config', async () => { + server = await listen(buildApp({ coordinator: makeCoordinator() })); + // Session A announces it is building the fix. + await fetch(`${server.url}/coordination/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'session-A' }, + body: JSON.stringify({ activity: 'building PR 495 fix for the redrive flood' }), + }); + // Session B flips the engine flag off (the "safety brake"). + const patch = await fetch(`${server.url}/config`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'session-B' }, + body: JSON.stringify({ monitoring: { collaborationRedrive: { enabled: false } } }), + }); + expect(patch.status).toBe(200); + const body = await patch.json(); + expect(body.success).toBe(true); + // The flip really happened (not blocked) ... + const written = JSON.parse(fs.readFileSync(path.join(projectDir, '.instar', 'config.json'), 'utf8')); + expect(written.monitoring.collaborationRedrive.enabled).toBe(false); + // ... AND it carried the advisory warning about session A. + expect(body.coordinationWarning).toBeTruthy(); + expect(body.coordinationWarning).toMatch(/config flip monitoring\.collaborationRedrive\.enabled/); + }); + + it('a commitment withdrawal while another session is active surfaces a warning (never blocks)', async () => { + server = await listen(buildApp({ coordinator: makeCoordinator() })); + await fetch(`${server.url}/coordination/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'session-A' }, + body: JSON.stringify({ activity: 'building the fix' }), + }); + const wResp = await fetch(`${server.url}/commitments/CMT-42/withdraw`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'session-B' }, + body: JSON.stringify({ reason: 'safety brake — withdrawing stale reply commitments' }), + }); + expect(wResp.status).toBe(200); + const w = await wResp.json(); + expect(w.withdrawn).toBe(true); // the action still succeeded — advisory only + expect(w.coordinationWarning).toBeTruthy(); + expect(w.coordinationWarning).toMatch(/another\/unknown session/); + }); + + it('passive (disabled) coordinator: GET still 200 but records nothing and never warns', async () => { + server = await listen(buildApp({ coordinator: makeCoordinator(false) })); + const intent = await fetch(`${server.url}/coordination/intent`, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Instar-Session': 'A' }, + body: JSON.stringify({ activity: 'thing' }), + }); + const ib = await intent.json(); + expect(ib.recorded).toBe(false); + const recent = await (await fetch(`${server.url}/coordination/recent`)).json(); + expect(recent.enabled).toBe(false); + expect(recent.count).toBe(0); + }); +}); diff --git a/tests/unit/ConfigDefaults.test.ts b/tests/unit/ConfigDefaults.test.ts index 8f2a98ef4..dac4f032c 100644 --- a/tests/unit/ConfigDefaults.test.ts +++ b/tests/unit/ConfigDefaults.test.ts @@ -34,6 +34,21 @@ describe('ConfigDefaults', () => { expect((defaults.monitoring as any).quotaTracking).toBe(true); }); + it('ships CrossSessionCoordination ON by default with the sensitive-key list (migration parity)', () => { + // Default-ON housekeeping; applyDefaults (add-missing) propagates it to + // existing agents on update. See docs/specs/cross-session-coordination.md. + for (const t of ['managed-project', 'standalone'] as const) { + const x = (getInitDefaults(t).monitoring as any).crossSessionCoordination; + expect(x).toBeDefined(); + expect(x.enabled).toBe(true); + expect(x.windowMs).toBe(600000); + expect(Array.isArray(x.sensitiveConfigKeys)).toBe(true); + expect(x.sensitiveConfigKeys).toContain('monitoring'); + } + const mig = getMigrationDefaults('managed-project'); + expect((mig.monitoring as any).crossSessionCoordination?.enabled).toBe(true); + }); + it('ships SessionReaper OFF + dry-run by default (the only kill-on-heuristic monitor)', () => { for (const t of ['managed-project', 'standalone'] as const) { const sr = (getInitDefaults(t).monitoring as any).sessionReaper; diff --git a/tests/unit/PostUpdateMigrator-crossSessionCoordination.test.ts b/tests/unit/PostUpdateMigrator-crossSessionCoordination.test.ts new file mode 100644 index 000000000..4f2f4004f --- /dev/null +++ b/tests/unit/PostUpdateMigrator-crossSessionCoordination.test.ts @@ -0,0 +1,119 @@ +/** + * Verifies PostUpdateMigrator adds the Cross-Session Coordination guidance section + * to existing agents' CLAUDE.md on update, and that generateClaudeMd emits it for + * fresh installs. Spec: docs/specs/cross-session-coordination.md. + * + * Migration parity: the coordinator is wired server-side, but the SIGNAL is only + * useful if a session knows to announce intent before high-impact actions and to + * STOP-and-confirm on a coordinationWarning. Without this section existing agents + * would have the routes but never reach for them — a half-shipped feature. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { PostUpdateMigrator } from '../../src/core/PostUpdateMigrator.js'; +import { SafeFsExecutor } from '../../src/core/SafeFsExecutor.js'; + +type MigrationResult = { upgraded: string[]; skipped: string[]; errors: string[] }; + +function newMigrator(projectDir: string): PostUpdateMigrator { + return new PostUpdateMigrator({ + projectDir, + stateDir: path.join(projectDir, '.instar'), + port: 4042, + hasTelegram: false, + projectName: 'test', + }); +} + +function runClaudeMdMigration(migrator: PostUpdateMigrator): MigrationResult { + const result: MigrationResult = { upgraded: [], skipped: [], errors: [] }; + (migrator as unknown as { migrateClaudeMd(r: MigrationResult): void }).migrateClaudeMd(result); + return result; +} + +const MARKER = 'Cross-Session Coordination (light, advisory)'; + +describe('PostUpdateMigrator — Cross-Session Coordination guidance', () => { + let projectDir: string; + let claudeMdPath: string; + + beforeEach(() => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'instar-xsession-mig-')); + fs.mkdirSync(path.join(projectDir, '.instar'), { recursive: true }); + claudeMdPath = path.join(projectDir, 'CLAUDE.md'); + }); + + afterEach(() => { + SafeFsExecutor.safeRmSync(projectDir, { + recursive: true, + force: true, + operation: 'tests/unit/PostUpdateMigrator-crossSessionCoordination.test.ts:cleanup', + }); + }); + + it('adds the section when CLAUDE.md does not already contain it', () => { + fs.writeFileSync(claudeMdPath, '# CLAUDE.md\n\nMy existing CLAUDE.md.\n'); + + const result = runClaudeMdMigration(newMigrator(projectDir)); + + expect(result.errors).toEqual([]); + expect(result.upgraded.some(u => u.includes('Cross-Session Coordination'))).toBe(true); + + const after = fs.readFileSync(claudeMdPath, 'utf-8'); + expect(after).toContain(MARKER); + expect(after).toContain('/coordination/intent'); + expect(after).toContain('/coordination/recent'); + expect(after).toContain('X-Instar-Session'); + // The STOP-and-confirm behavioral rule. + expect(after).toContain('coordinationWarning'); + }); + + it('is idempotent — re-running does not add a duplicate section', () => { + fs.writeFileSync(claudeMdPath, '# CLAUDE.md\n\nMy existing CLAUDE.md.\n'); + + runClaudeMdMigration(newMigrator(projectDir)); + const afterFirst = fs.readFileSync(claudeMdPath, 'utf-8'); + + const result2 = runClaudeMdMigration(newMigrator(projectDir)); + const afterSecond = fs.readFileSync(claudeMdPath, 'utf-8'); + + expect(result2.errors).toEqual([]); + expect(result2.upgraded.some(u => u.includes('Cross-Session Coordination'))).toBe(false); + expect(afterSecond).toBe(afterFirst); + const headingMatches = afterSecond.match(/### Cross-Session Coordination \(light, advisory\)/g); + expect(headingMatches?.length).toBe(1); + }); + + it('preserves existing CLAUDE.md content', () => { + const original = '# CLAUDE.md\n\n## My Custom Section\n\nDo not delete this.\n'; + fs.writeFileSync(claudeMdPath, original); + + runClaudeMdMigration(newMigrator(projectDir)); + const after = fs.readFileSync(claudeMdPath, 'utf-8'); + + expect(after.startsWith(original)).toBe(true); + expect(after.length).toBeGreaterThan(original.length); + }); + + it('skips gracefully when CLAUDE.md is missing', () => { + expect(fs.existsSync(claudeMdPath)).toBe(false); + const result = runClaudeMdMigration(newMigrator(projectDir)); + expect(result.errors).toEqual([]); + expect(result.skipped.some(s => s.includes('CLAUDE.md'))).toBe(true); + }); +}); + +describe('generateClaudeMd template includes Cross-Session Coordination section', () => { + it('the source template emits the section so fresh installs get it too', () => { + const templateSource = fs.readFileSync( + path.join(process.cwd(), 'src/scaffold/templates.ts'), + 'utf-8', + ); + expect(templateSource).toContain(MARKER); + expect(templateSource).toContain('/coordination/intent'); + expect(templateSource).toContain('BEFORE any high-impact structural action'); + }); +}); diff --git a/tests/unit/cross-session-coordinator.test.ts b/tests/unit/cross-session-coordinator.test.ts new file mode 100644 index 000000000..9fecbbc98 --- /dev/null +++ b/tests/unit/cross-session-coordinator.test.ts @@ -0,0 +1,192 @@ +/** + * Tier-1 unit tests for CrossSessionCoordinator (the light cross-session + * coordination signal). Spec: docs/specs/cross-session-coordination.md. + * + * Covers both sides of every decision boundary: enabled/disabled, in-window vs + * out-of-window, same-actor vs different-actor vs unknown-actor, identical-action + * dedupe, retention prune, cap, and reload-per-op persistence. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CrossSessionCoordinator } from '../../src/monitoring/CrossSessionCoordinator.js'; +import { SafeFsExecutor } from '../../src/core/SafeFsExecutor.js'; + +describe('CrossSessionCoordinator', () => { + let tmpDir: string; + let stateDir: string; + let clock: number; + const now = () => clock; + + function make(overrides: Partial[0]> = {}) { + return new CrossSessionCoordinator({ stateDir, now, ...overrides }); + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xsession-')); + stateDir = path.join(tmpDir, '.instar'); + fs.mkdirSync(stateDir, { recursive: true }); + clock = 1_700_000_000_000; + }); + + afterEach(() => { + SafeFsExecutor.safeRmSync(tmpDir, { recursive: true, force: true, operation: 'tests/unit/cross-session-coordinator.test.ts' }); + }); + + it('records an action and persists it to disk atomically', () => { + const c = make(); + const r = c.record({ kind: 'intent', reason: 'building PR 495 fix', actor: 'topic-15579' }); + expect(r.recorded).toBe(true); + expect(r.id).toMatch(/^intent-/); + const storePath = path.join(stateDir, 'state', 'cross-session-actions.json'); + expect(fs.existsSync(storePath)).toBe(true); + const store = JSON.parse(fs.readFileSync(storePath, 'utf8')); + expect(store.actions).toHaveLength(1); + expect(store.actions[0].reason).toBe('building PR 495 fix'); + // No tmp file left behind + expect(fs.readdirSync(path.join(stateDir, 'state')).some((f) => f.includes('.tmp'))).toBe(false); + }); + + it('writes a JSONL audit line per record', () => { + const c = make(); + c.record({ kind: 'config-flag', target: 'monitoring.collaborationRedrive.enabled', value: false }); + const auditPath = path.join(stateDir, 'logs', 'cross-session-events.jsonl'); + expect(fs.existsSync(auditPath)).toBe(true); + const lines = fs.readFileSync(auditPath, 'utf8').trim().split('\n'); + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + expect(entry.kind).toBe('config-flag'); + expect(entry.value).toBe(false); + expect(entry.recordedAt).toBeTruthy(); + }); + + it('returns no warning when no other session has acted', () => { + const c = make(); + const r = c.record({ kind: 'config-flag', target: 'monitoring.x.enabled', value: false, actor: 'A' }); + expect(r.concurrent).toHaveLength(0); + expect(r.warning).toBeNull(); + }); + + it('flags a DIFFERENT actor acting within the window (the core signal)', () => { + const c = make(); + c.record({ kind: 'commitment-withdraw', target: 'CMT-1', actor: 'session-A' }); + clock += 4 * 60 * 1000; // 4 min later + const r = c.record({ kind: 'config-flag', target: 'monitoring.redrive.enabled', value: false, actor: 'session-B' }); + expect(r.concurrent).toHaveLength(1); + expect(r.concurrent[0].actor).toBe('session-A'); + expect(r.warning).toMatch(/Cross-session/); + expect(r.warning).toMatch(/another\/unknown session/); + }); + + it('does NOT flag the SAME known actor (no self-warning)', () => { + const c = make(); + c.record({ kind: 'commitment-withdraw', target: 'CMT-1', actor: 'session-A' }); + clock += 60 * 1000; + const r = c.record({ kind: 'commitment-withdraw', target: 'CMT-2', actor: 'session-A' }); + expect(r.concurrent).toHaveLength(0); + expect(r.warning).toBeNull(); + }); + + it('treats UNKNOWN actor as potentially-different (includes it)', () => { + const c = make(); + c.record({ kind: 'intent', reason: 'doing thing', actor: undefined }); + clock += 60 * 1000; + const r = c.record({ kind: 'config-flag', target: 'monitoring.redrive.enabled', value: false, actor: 'session-B' }); + expect(r.concurrent).toHaveLength(1); + expect(r.warning).toMatch(/unattributed session/); + }); + + it('does NOT flag actions older than the window', () => { + const c = make({ windowMs: 10 * 60 * 1000 }); + c.record({ kind: 'commitment-withdraw', target: 'CMT-1', actor: 'session-A' }); + clock += 11 * 60 * 1000; // outside the 10-min window + const r = c.record({ kind: 'config-flag', target: 'x', value: false, actor: 'session-B' }); + expect(r.concurrent).toHaveLength(0); + expect(r.warning).toBeNull(); + }); + + it('does NOT double-count the literal same action (kind+target+value)', () => { + const c = make(); + // Same action key, but recorded under different actors → identical-action dedupe applies. + c.record({ kind: 'config-flag', target: 'monitoring.x.enabled', value: false, actor: 'A' }); + clock += 1000; + const r = c.record({ kind: 'config-flag', target: 'monitoring.x.enabled', value: false, actor: 'B' }); + expect(r.concurrent).toHaveLength(0); + }); + + it('treats two DISTINCT intents (no target/value) as concurrent, not the "same action"', () => { + // Regression: intents have no target/value, so a kind+target+value identity + // would collapse them into one and suppress the cross-session warning. Intents + // are events — each announcement is distinct and must surface. + const c = make(); + c.recordIntent('building the fix', { actor: 'session-A' }); + clock += 60 * 1000; + const r = c.recordIntent('hitting the safety brake', { actor: 'session-B' }); + expect(r.concurrent).toHaveLength(1); + expect(r.concurrent[0].actor).toBe('session-A'); + expect(r.warning).toMatch(/another\/unknown session/); + }); + + it('prunes actions older than retentionMs on write', () => { + const c = make({ retentionMs: 60 * 60 * 1000 }); + c.record({ kind: 'intent', reason: 'old', actor: 'A' }); + clock += 61 * 60 * 1000; // past retention + c.record({ kind: 'intent', reason: 'fresh', actor: 'B' }); + const store = JSON.parse(fs.readFileSync(path.join(stateDir, 'state', 'cross-session-actions.json'), 'utf8')); + expect(store.actions).toHaveLength(1); + expect(store.actions[0].reason).toBe('fresh'); + }); + + it('caps the ledger at maxActions (newest kept)', () => { + const c = make({ maxActions: 5 }); + for (let i = 0; i < 12; i++) { + clock += 1000; + c.record({ kind: 'other', target: `t${i}`, actor: 'A' }); + } + const store = JSON.parse(fs.readFileSync(path.join(stateDir, 'state', 'cross-session-actions.json'), 'utf8')); + expect(store.actions).toHaveLength(5); + expect(store.actions[store.actions.length - 1].target).toBe('t11'); + }); + + it('disabled mode records nothing and never warns', () => { + const c = make({ enabled: false }); + const r = c.record({ kind: 'config-flag', target: 'x', value: false }); + expect(r.recorded).toBe(false); + expect(r.warning).toBeNull(); + expect(fs.existsSync(path.join(stateDir, 'state', 'cross-session-actions.json'))).toBe(false); + expect(c.isEnabled()).toBe(false); + }); + + it('getRecent returns newest-first within retention', () => { + const c = make(); + c.record({ kind: 'intent', reason: 'first', actor: 'A' }); + clock += 1000; + c.record({ kind: 'intent', reason: 'second', actor: 'B' }); + const recent = c.getRecent(); + expect(recent).toHaveLength(2); + expect(recent[0].reason).toBe('second'); + expect(recent[1].reason).toBe('first'); + }); + + it('reload-per-op: a second instance sees the first instance writes (cross-process safety)', () => { + const c1 = make(); + c1.record({ kind: 'intent', reason: 'from c1', actor: 'A' }); + // Fresh instance, same stateDir — simulates a second server process / restart. + const c2 = make(); + clock += 1000; + const r = c2.record({ kind: 'config-flag', target: 'x', value: false, actor: 'B' }); + expect(r.concurrent).toHaveLength(1); + expect(r.concurrent[0].reason).toBe('from c1'); + }); + + it('recordIntent is a convenience wrapper for kind=intent', () => { + const c = make(); + const r = c.recordIntent('rebuilding the engine', { actor: 'topic-9', area: 'collaborationRedrive' }); + expect(r.recorded).toBe(true); + const recent = c.getRecent(); + expect(recent[0].kind).toBe('intent'); + expect(recent[0].reason).toBe('rebuilding the engine'); + expect(recent[0].target).toBe('collaborationRedrive'); + }); +}); diff --git a/upgrades/NEXT.md b/upgrades/NEXT.md new file mode 100644 index 000000000..cc450c9dd --- /dev/null +++ b/upgrades/NEXT.md @@ -0,0 +1,66 @@ +# Upgrade Guide — vNEXT + + + + +## What Changed + +Added a **CrossSessionCoordinator** — a light, advisory cross-session coordination +signal. A single agent home can run multiple concurrent Claude Code sessions against +the same `.instar/` state, and they are blind to each other. On 2026-05-28 two +sessions took opposing durable actions — one built a fix while another flipped a +feature flag off and withdrew 19 commitments — with neither aware of the other. + +The coordinator is a shared, append-only scratchpad of recent high-impact structural +actions plus voluntary "I'm about to do X" intents. Any structural action surfaces +other recent entries by a different or unknown session as an advisory +`coordinationWarning`. It never blocks and never mutates the target state — it is +purely a heads-up. + +- New routes: `POST /coordination/intent` (announce an intent) and + `GET /coordination/recent` (inspect the ledger, newest first). +- Backstop auto-recording: sensitive `PATCH /config` flips and + `POST /commitments/:id/withdraw` calls record themselves and attach a + `coordinationWarning` to their own response when another session was recently active + — so the signal works even without an explicit announcement. +- Wired into AgentServer (always alive — read routes return 200, not 503) with a + CapabilityIndex entry under `/coordination`. +- Default-ON housekeeping, near-silent (no Telegram). Audit trail at + `logs/cross-session-events.jsonl`. Config: `monitoring.crossSessionCoordination`. +- Migration parity: config default ships to existing agents via ConfigDefaults, and a + CLAUDE.md awareness section is added via `migrateClaudeMd` + `generateClaudeMd`. + +## What to Tell Your User + +- **Sessions that notice each other**: "If more than one of me is ever working at the + same time, we can now see each other's big moves before acting — so we don't + accidentally undo each other's work. It's a gentle heads-up, not a lock: nothing + gets blocked, I just get nudged to double-check with you first when another me was + recently active." + +## Summary of New Capabilities + +| Capability | How to Use | +|-----------|-----------| +| Announce a cross-session intent | POST /coordination/intent | +| Inspect recent cross-session actions | GET /coordination/recent | +| Advisory warning on config flips and commitment withdrawals | automatic | + +## Evidence + +Driven by two real incidents on 2026-05-28 (topic 15579): (1) a stale "still working" +flag, where one session kept narrating progress over another session's already-completed +work; and (2) two sessions taking opposing durable actions — one built the proper fix +while a second flipped a feature flag off and mass-withdrew 19 commitments, neither +aware of the other, so the bug was fixed but the engine was left off and the test bed +was wiped. + +Verification: three test tiers green. Unit (16) includes a regression test for an +intent-dedup defect found during implementation review — two distinct intents were +collapsing into one and suppressing the warning; fixed so intents are never deduped. +Integration (8) exercises both incident vectors over live HTTP: a config-flag flip and +a commitment withdrawal each return the advisory warning while the action itself still +succeeds (advisory, never blocking). An e2e lifecycle test (6) boots the real +AgentServer and confirms GET /coordination/recent returns 200 with the signal surfacing +end-to-end. Migration-parity tests (5) confirm existing agents receive the config +default and CLAUDE.md awareness on update. diff --git a/upgrades/side-effects/cross-session-coordination.md b/upgrades/side-effects/cross-session-coordination.md new file mode 100644 index 000000000..d7078eaf9 --- /dev/null +++ b/upgrades/side-effects/cross-session-coordination.md @@ -0,0 +1,94 @@ +# Side-Effects Review — Cross-Session Coordination Signal (light, advisory) + +**Version / slug:** `cross-session-coordination` +**Date:** 2026-05-28 +**Author:** Echo +**Spec:** `docs/specs/cross-session-coordination.md` (approved: Justin, topic 15579) + +## Summary of the change + +Adds a light, advisory cross-session coordination signal so concurrent Claude Code +sessions on one agent home can see each other's recent high-impact actions before +acting. Never blocks; never mutates target state. + +Files: + +1. `src/monitoring/CrossSessionCoordinator.ts` — **new.** Append-only ledger + (`state/cross-session-actions.json`, atomic temp+rename, reload-per-op, TTL prune, + hard cap) + JSONL audit (`logs/cross-session-events.jsonl`). `record()` computes + `concurrent` = other recent actions within `windowMs` by a different/unknown actor, + and returns an advisory `warning`. Advisory catch blocks carry `@silent-fallback-ok`. +2. `src/server/routes.ts` — module helpers (`DEFAULT_SENSITIVE_CONFIG_KEYS`, + `flattenConfigFlips`, `coordinationActor`); new routes `POST /coordination/intent` + and `GET /coordination/recent`; `PATCH /config` records sensitive-key flips and + `POST /commitments/:id/withdraw` records withdrawals, each attaching a + `coordinationWarning` to its own response. New `RouteContext.crossSessionCoordinator`. +3. `src/server/AgentServer.ts` — constructs the coordinator (own try/catch, always + alive so reads stay 200) and injects it into the route context. +4. `src/config/ConfigDefaults.ts` — `monitoring.crossSessionCoordination` default + (enabled, windowMs, retentionMs, sensitiveConfigKeys). +5. `src/server/CapabilityIndex.ts` — discoverability entry under `/coordination`. +6. `src/core/PostUpdateMigrator.ts` + `src/scaffold/templates.ts` — CLAUDE.md awareness + section (migration + fresh-install template). +7. `src/data/builtin-manifest.json` — regenerated. +8. Tests: unit, integration (routes), e2e (lifecycle), migration-parity, ConfigDefaults. + +## Decision-point inventory + +- Advisory-only contract (record + warn, never block, never mutate) — **core decision.** +- Single-source recording of withdrawals in the route handler (not the + CommitmentTracker event) — **decision** (avoids double-count; route is the single + agent-facing path). +- Actor resolution is SESSION-level only, never agent id — **decision** (agent id is + shared across a session and would suppress the warning). +- Intents are never deduped; only state-flips dedupe on kind+target+value — **decision.** +- Default-ON, no Telegram in v1 — **decision** (near-silent; avoids topic-spam). + +## 1. Over-block + +Nothing is blocked — the signal is advisory by contract. The worst over-reaction is a +`coordinationWarning` surfacing when two structural actions by different/unknown actors +land within `windowMs`, including the benign case of one operator running two of their +own sessions. The warning is informational; the action always proceeds. Unknown-actor +is treated as "potentially different," so a session that omits `X-Instar-Session` may +see a warning about its own earlier unattributed action — noisy but harmless, and +fixed by passing the header (documented in the awareness section). + +## 2. Under-block + +The signal only surfaces a warning, never prevents the opposing action, so the +original incident (two sessions taking opposing durable actions) is *surfaced*, not +*prevented*. That is the intended light-fix scope — Justin explicitly chose advisory +over hard locks. A session that ignores the warning still acts. Code-internal +commitment lifecycle transitions (expiry) are not recorded; only agent-initiated +route-driven withdrawals are. + +## 3. Persistence / corruption + +Ledger writes are atomic temp+rename and reload-per-op, so a second server process +can't clobber. A missing/corrupt ledger file reads as empty (correct first-run state). +All persistence failures are swallowed (`@silent-fallback-ok`) because a dropped ledger +write must never break the calling route — at worst a future advisory is weaker. + +## 4. Migration parity + +Config default lands in `SHARED_DEFAULTS`; `applyDefaults` (add-missing deep merge) +propagates `monitoring.crossSessionCoordination` to existing agents on update — no +bespoke config migration needed (unit-tested). CLAUDE.md awareness is added via +`migrateClaudeMd` (content-sniffed, idempotent) for existing agents and +`generateClaudeMd` for fresh installs (both unit-tested). The coordinator is +constructed default-on in AgentServer even when config lacks the key, so existing +agents get the behavior immediately on the new server version. + +## 5. Rollback + +Set `monitoring.crossSessionCoordination.enabled: false` → passive: `GET +/coordination/recent` still returns 200 (`enabled:false`), nothing is recorded, no +warnings. No data migration, no schema. Reverting the code removes two read/record +routes and an advisory field on two existing responses; no stored state depends on it. + +## 6. Blast radius + +In-process only. Touches two existing response payloads additively +(`coordinationWarning` is only present when non-null). No external calls, no Telegram, +no session kills, no file mutation outside the agent's own `state/` + `logs/`.