instar 1.3.533 → 1.3.535
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +98 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/core/CredentialWriteFunnel.d.ts +16 -0
- package/dist/core/CredentialWriteFunnel.d.ts.map +1 -1
- package/dist/core/CredentialWriteFunnel.js +19 -0
- package/dist/core/CredentialWriteFunnel.js.map +1 -1
- package/dist/core/EscalationHintStore.d.ts +60 -0
- package/dist/core/EscalationHintStore.d.ts.map +1 -0
- package/dist/core/EscalationHintStore.js +163 -0
- package/dist/core/EscalationHintStore.js.map +1 -0
- package/dist/core/ModelTierEscalation.d.ts +10 -0
- package/dist/core/ModelTierEscalation.d.ts.map +1 -1
- package/dist/core/ModelTierEscalation.js +4 -0
- package/dist/core/ModelTierEscalation.js.map +1 -1
- package/dist/core/OAuthRefresher.d.ts +18 -1
- package/dist/core/OAuthRefresher.d.ts.map +1 -1
- package/dist/core/OAuthRefresher.js +23 -1
- package/dist/core/OAuthRefresher.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +15 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/QuotaPoller.d.ts.map +1 -1
- package/dist/core/QuotaPoller.js +7 -0
- package/dist/core/QuotaPoller.js.map +1 -1
- package/dist/core/TopicProfileTransferCarrier.d.ts +27 -0
- package/dist/core/TopicProfileTransferCarrier.d.ts.map +1 -1
- package/dist/core/TopicProfileTransferCarrier.js +24 -1
- package/dist/core/TopicProfileTransferCarrier.js.map +1 -1
- package/dist/monitoring/AccountSwitcher.d.ts.map +1 -1
- package/dist/monitoring/AccountSwitcher.js +13 -2
- package/dist/monitoring/AccountSwitcher.js.map +1 -1
- package/dist/monitoring/CredentialProvider.d.ts +20 -0
- package/dist/monitoring/CredentialProvider.d.ts.map +1 -1
- package/dist/monitoring/CredentialProvider.js +26 -0
- package/dist/monitoring/CredentialProvider.js.map +1 -1
- package/dist/scaffold/templates.d.ts.map +1 -1
- package/dist/scaffold/templates.js +1 -0
- package/dist/scaffold/templates.js.map +1 -1
- package/dist/server/AgentServer.d.ts +10 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +26 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +6 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +47 -1
- package/dist/server/routes.js.map +1 -1
- package/package.json +4 -2
- package/scripts/lint-no-direct-destructive.js +4 -0
- package/scripts/lint-no-unfunneled-credential-write.js +155 -0
- package/src/data/builtin-manifest.json +64 -64
- package/src/scaffold/templates.ts +1 -0
- package/upgrades/1.3.535.md +66 -0
- package/upgrades/side-effects/live-credential-repointing-increment-a-funnel-routing.md +86 -0
- package/upgrades/side-effects/ws53-escalation-rides-topic.md +52 -0
- package/upgrades/1.3.533.md +0 -25
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Side-Effects Review — Live credential re-pointing (Increment A, Step 4b: route writers through the funnel + forbidding lint)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `live-credential-repointing-increment-a-funnel-routing`
|
|
4
|
+
**Date:** `2026-06-13`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `required` (touches the live token-refresh hot path — see §5/§7; result appended below)
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
Routes every in-process write to the `Claude Code-credentials` keychain store through the Step-4a `CredentialWriteFunnel` (spec §2.2), and adds the forbidding lint that makes the routing structural:
|
|
11
|
+
|
|
12
|
+
- **`OAuthRefresher.refreshClaudeToken`** — its single `store.write` is now wrapped in `funnel.withSlotLock(configHome, …)`. A lock-acquire timeout returns the NEW typed reason `write-skipped` (the exchange already succeeded; the existing, still-valid credential is untouched). `RefreshDeps.funnel` is injectable, defaulting to the process-wide `credentialWriteFunnel` singleton.
|
|
13
|
+
- **`QuotaPoller.pollAccount`** — maps `write-skipped` to "no snapshot this cycle, retry next tick", explicitly NOT `markNeedsReauth`. A busy lock can never cry-wolf a healthy login into needs-reauth.
|
|
14
|
+
- **`CredentialProvider`** — adds `writeCredentialsSerialized(provider, slot, creds, funnel?)`, the sanctioned chokepoint that wraps `provider.writeCredentials` in `withSlotLock`. `KeychainCredentialProvider.writeCredentials` (the raw `security -i` write) is unchanged — it is a primitive; callers serialize.
|
|
15
|
+
- **`AccountSwitcher.switchAccount`** — now writes via `writeCredentialsSerialized(...)`; a busy lock returns a non-destructive "store busy, try again", never a corrupting half-write.
|
|
16
|
+
- **`scripts/lint-no-unfunneled-credential-write.js`** (+ wired into `npm run lint`, allowlisted in `lint-no-direct-destructive.js`) — forbids, outside the closed allowlist (the funnel + the two primitive owners + the lint itself): `defaultCredentialStore.write(`, a qualified `.writeCredentials(`, and a raw `add-generic-password` in a file that targets the `Claude Code-credentials` service (file-scoped so the OTHER vaults — WorktreeKeyVault / SecretStore / GlobalSecretStore / RemediationKeyVault, each a distinct service — never false-positive).
|
|
17
|
+
- Tests: `credential-write-routing.test.ts` (7) + `lint-no-unfunneled-credential-write.test.ts` (8).
|
|
18
|
+
|
|
19
|
+
Ships DARK: the credential-repointing feature gate is untouched and stays off+dry-run. The funnel is pure in-process serialization with NO behavioral change when no swap is running (which is always, while dark) — it only adds per-slot ordering and a bounded skip-and-retry under contention.
|
|
20
|
+
|
|
21
|
+
## Decision-point inventory
|
|
22
|
+
|
|
23
|
+
- `refreshClaudeToken` write → `withSlotLock` — **modify** — serialize the refresh write; contention → `write-skipped` (retry), never corruption.
|
|
24
|
+
- `QuotaPoller` `write-skipped` mapping — **add** — a busy lock is NOT a dead login; no `needs-reauth`.
|
|
25
|
+
- `writeCredentialsSerialized` — **add** — the one sanctioned provider-write chokepoint.
|
|
26
|
+
- `lint-no-unfunneled-credential-write` — **add** — structural enforcement that no future writer bypasses the funnel. Signal-only (a build-time lint), no runtime authority.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Over-block
|
|
31
|
+
|
|
32
|
+
The only new "refusal" is a try-lock-timeout SKIP, which is the §2.2 bounded-wait contract, not a content decision. A legitimate refresh write that loses the 15s race for its slot is reported `write-skipped` and retried next poll cycle — it is never dropped or failed. A legitimate account-switch write that loses the race gets a "store busy, try again" and the operator retries. Neither rejects a valid input; both defer it by one cycle. Because the feature ships dark (no swaps run), the only lock holders are these writes themselves (sub-millisecond `security` calls), so contention — and therefore any skip — is effectively unreachable until Step 5 introduces a longer-held swap lock.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. Under-block
|
|
37
|
+
|
|
38
|
+
The funnel can only serialize a writer that routes through it. That is exactly what the new lint enforces: the real tree is now lint-clean, and any future callsite that hand-rolls a `Claude Code-credentials` write (raw `security`, `defaultCredentialStore.write`, or `provider.writeCredentials`) outside the allowlist fails the build. The lint is line-scoped and skips comments (a documentation mention is not a bypass) and file-scoped to the guarded service (the four sibling keychain vaults are out of scope by construction, verified: none contains the `Claude Code-credentials` literal). Residual not covered: a brand-new keychain service string for Claude credentials would not be caught by the service-literal scope — acceptable, because the service name is a fixed Anthropic-client constant (`claudeCredentialService`), not something a new writer invents.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 3. Level-of-abstraction fit
|
|
43
|
+
|
|
44
|
+
Correct layer. The wrapping lives in the two files that OWN the write primitives (`OAuthRefresher`, `CredentialProvider`) — the SafeGit/SafeFs precedent of "the primitive's owner is the funnel-internal site, everything else routes through a named method." The QuotaPoller maps the new reason at the exact point it already classifies refresh outcomes. The lint mirrors `lint-no-unfunneled-topic-creation` / `-headless-launch` precisely (closed allowlist, comment-skipping, `--staged` bootstrap). No higher layer should own this — credential writes are a `src/core`/`src/monitoring` concern.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## 4. Signal vs authority compliance
|
|
49
|
+
|
|
50
|
+
Compliant. The funnel is mechanism (a per-slot mutex with a bounded outcome), not authority over agent behavior — it makes no allow/deny decision about content, only about lock availability, deterministically. The QuotaPoller mapping REMOVES a false-authority failure mode (a transient lock contention was never a real `needs-reauth`, and now can't masquerade as one). The lint is a build-time signal with zero runtime authority. No brittle check gains blocking authority over messaging, dispatch, or sessions.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 5. Interactions
|
|
55
|
+
|
|
56
|
+
- **Hot path — token refresh:** the load-bearing interaction. `refreshClaudeToken` keeps every session's access token fresh; the only change to it is that its write is serialized. The existing 17 oauth-refresher + quota-poller + credential tests (193 → all green) prove the default-singleton path is behaviorally identical to today (a free lock ⇒ `ran:true` ⇒ same write, same result). The single NEW path is `ran:false` ⇒ `write-skipped` ⇒ QuotaPoller returns no-snapshot — covered by a dedicated test plus the CONTRAST test proving `exchange-failed` still marks `needs-reauth`.
|
|
57
|
+
- **No double-lock / deadlock:** the primitives (`store.write`, `provider.writeCredentials`) do NOT self-lock; callers lock once. `writeCredentialsSerialized` is the only locker for the provider path, so AccountSwitcher → serialized → provider.write is a single lock acquisition. Verified by the per-slot-isolation test (a busy slot does not block a different slot).
|
|
58
|
+
- **Shared singleton:** refresh, switch, and (Step 5) swap all use `credentialWriteFunnel`, so a refresh and a switch on the SAME slot genuinely serialize. Slot keys are the expanded config home; the default slot is `expandHome('~/.claude')` on both paths so they share one lock.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 6. External surfaces
|
|
63
|
+
|
|
64
|
+
No new routes, no network, no notices. The only externally observable change is benign: an account-switch that hits a busy lock returns a clearer "store busy, try again" message instead of proceeding, and a refresh that hits a busy lock logs one warn line and skips a single poll cycle. Token values are never logged (the skip reason and warn line are credential-free). Nothing changes for other agents/users/machines.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
69
|
+
|
|
70
|
+
**Machine-local BY DESIGN.** Credential writes target THIS machine's keychain, so the serialization that protects them is inherently per-process/per-machine — there is no shared state to replicate. The funnel singleton serializes writers WITHIN one process; cross-machine coordination of a credential move (a topic transfer mid-swap) is the existing handoff guard's responsibility, composed-with in Step 5, not this lock's. A second machine refreshing its OWN keychain copy of the same account is independent by construction (each machine has its own login lineage) — the spec's one-home-per-credential invariant is about config homes on a single machine, enforced by the ledger (Step 2), not by this lock.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 8. Rollback cost
|
|
75
|
+
|
|
76
|
+
Low. Plain `git revert` of this commit restores the direct writes; no migration, no persisted state, no schema. The funnel singleton and primitive remain (shipped in Step 4a) but go unused. Because the feature is dark and the change is behaviorally identical under no-contention, a revert is invisible to production. The lint removal is a one-line edit to `npm run lint` if ever needed.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Second-pass review
|
|
81
|
+
|
|
82
|
+
_Appended by the dedicated independent reviewer subagent (Phase 5), 2026-06-13._
|
|
83
|
+
|
|
84
|
+
**VERDICT: CONCUR.** The reviewer independently traced every hot-path concern and confirmed: tests 15/15 green; no hot-path regression (the `write-skipped` path returns no-snapshot and never `markNeedsReauth` — verified end-to-end through `QuotaPoller.pollAccount`); no lock-wedge (acquisition is bounded; `mine` releases only after the real prior holder settles; every `fn` here is a synchronous `security`/fs call); no double-lock/deadlock (neither primitive self-locks; `writeCredentialsSerialized` is the sole locker on the provider path); the lint is sound on both axes (regex matches qualified calls but not the method definition / interface / `writeCredentialsSerialized`; the raw-keychain rule is file-scoped to the guarded service so the four sibling vaults never false-positive); behaviorally inert when dark.
|
|
85
|
+
|
|
86
|
+
**One non-blocking concern raised → FIXED in this commit (not deferred).** The reviewer noted the per-slot lock key was string-identity-fragile: a default account enrolled non-canonically (`~/.claude/` with a trailing slash, or a differently-spelled path) could key the refresh write and the switch write to *different* locks for the *same* keychain entry, letting them race. Resolution: added `credentialSlotKey(configHome) = path.resolve(expandHome(configHome))` in `OAuthRefresher.ts` and routed all three lock keys through it (`refreshClaudeToken`, `DEFAULT_CREDENTIAL_SLOT`, `writeCredentialsSerialized`), so the shared-resource→lock-key mapping is canonical rather than dependent on operator spelling. This strictly improves on today's behavior (which has no funnel at all) and removes the latent footgun before Step 5's longer-held swap lock can expose it.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Side-Effects Review — WS5.3 escalation rides the topic (model-tier escalation follows a moved topic)
|
|
2
|
+
|
|
3
|
+
**Spec:** docs/specs/ws53-escalation-rides-topic.md (converged + approved — operator pre-approval, Justin topic 13481). **Parent:** Cross-Machine Coherence — One Agent, Robust Under Degraded Conditions.
|
|
4
|
+
**Ships DARK** behind `models.tierEscalation.ridesTopic` (default false) under the existing `tierEscalation.enabled`. Single-machine installs are a strict no-op.
|
|
5
|
+
**Files:** src/core/EscalationHintStore.ts (new), src/core/ModelTierEscalation.ts, src/core/TopicProfileTransferCarrier.ts, src/server/routes.ts, src/server/AgentServer.ts, src/commands/server.ts, src/scaffold/templates.ts, src/core/PostUpdateMigrator.ts
|
|
6
|
+
|
|
7
|
+
## What changed
|
|
8
|
+
|
|
9
|
+
1. **EscalationHintStore.ts (new):** the ephemeral per-topic escalation-hint carrier — `file` / `peek` (no consume) / `consume` (consume-once) / `clear` / `all`. TTL-bounded (default 6h, mirroring `maxEscalationTtlMs`), atomic tmp+rename writes, corrupt/absent file ⇒ no hints (the safe direction). NOT the durable topic profile — its own file `state/model-tier-escalation/rides-topic-hints.json`.
|
|
10
|
+
2. **ModelTierEscalation.ts:** new `ridesTopic: boolean` field on `TierEscalationConfig`, `ridesTopic: false` in `DEFAULT_TIER_ESCALATION_CONFIG`, and carried by `normalizeTierEscalationConfig` (the read-side add-missing normalizer every consumer goes through).
|
|
11
|
+
3. **TopicProfileTransferCarrier.ts:** the hint rides the EXISTING `topic-profile-pull` acquire pull. `TopicProfilePullEntry` gains an optional `escalationHint`; the serve handler gains an optional `escalationHintPeek` (peek, not consume); the carrier gains `onEscalationHintLanded`, fired in `applyLanding` AFTER the mandatory ownership recheck (so a hint never drives a re-admit on a non-owner) and independently of the durable-profile present/absent branch (an escalated topic with no pin still carries).
|
|
12
|
+
4. **routes.ts (`POST /pool/transfer` source leg):** when WS5.3 is enabled and the moving topic has a LIVE session on this machine on an escalated model id, and the topic is not `escalationOverride:'suppress'`, file the ephemeral hint (`trigger:'transfer'` audit label only). Only a REAL move files (noop/already-there never does). New `RouteContext.escalationHints?`. Response carries `escalationHintFiled:true` when one was filed. Imports `escalatedModelIds` + `normalizeTierEscalationConfig`.
|
|
13
|
+
5. **AgentServer.ts:** constructs the EscalationHintStore inside the existing model-tier block (cheap, file-backed, inert while dark) so the seams are ALIVE on the production init path; passes it to RouteContext; exposes `getEscalationHintStore()`; resets to null in the existing cascade-isolation catch.
|
|
14
|
+
6. **server.ts:** the destination re-admit driver `_driveEscalationReadmit` (bound after the AgentServer exists) — resolves the topic's resumed session and calls `ModelSwapService.swap(name,'escalated')` (serialized through the orchestrator's per-topic lock); the `topic-profile-pull` serve handler now passes `escalationHintPeek` (config-gated); the carrier wiring passes `onEscalationHintLanded`; `spawnSessionForTopic` consumes the LOCAL hint after the resumed-session spawn (the same-machine target==self arm). `normalizeTierEscalationConfig` promoted to a top-level static import.
|
|
15
|
+
7. **templates.ts + PostUpdateMigrator.ts:** an "Escalation rides a moved topic (WS5.3)" bullet EXTENDS the existing Model-Tier Escalation section — `generateClaudeMd` (new agents) + the section-install template AND an idempotent content-sniffed additive-bullet patcher (existing agents that already carry the section).
|
|
16
|
+
|
|
17
|
+
## Blast radius
|
|
18
|
+
|
|
19
|
+
- **The whole path is config-gated, not wiring-gated.** With `tierEscalation.enabled` false (the fleet default), every seam is a strict no-op: the source files nothing, the serve handler peeks nothing, the driver returns early, the local consume is empty. The EscalationHintStore is always constructed (so the feature can be turned on without a restart-to-rewire), but it does nothing until `ridesTopic` AND `enabled` are both true.
|
|
20
|
+
- **No bypass surface.** The destination re-admit is literally `ModelSwapService.swap(name,'escalated')` → `governor.admitEscalation()`. There is no second admit path. The hint only decides whether to CALL swap('escalated'); the answer always comes from the governor's full cost-guard chain. A refusal leaves the session on its default tier — exactly as a fresh escalation request would be refused.
|
|
21
|
+
- **Ownership-gated actuation.** The cross-machine landing fires the re-admit ONLY after the carrier's existing ownership recheck confirms this machine owns the topic. A hint that lands on a re-transferred topic is skipped (audited), never actuated.
|
|
22
|
+
- **No new HTTP route, no new MeshRpc verb.** The hint rides the EXISTING authenticated `topic-profile-pull` pull and the EXISTING `/pool/transfer` — N-machine-safe, no broadcast, no LAN assumption.
|
|
23
|
+
|
|
24
|
+
## Risk + mitigation
|
|
25
|
+
|
|
26
|
+
- **Risk:** a stale or forged hint drives a wrong escalation. **Mitigation:** the hint's `trigger` label is audit-only — the destination governor re-evaluates from REAL state, so a hint claiming a trigger the topic isn't under still has to pass every real guard. An expired hint (TTL) is treated as absent. Proven by the stale/forged-hint lens test.
|
|
27
|
+
- **Risk:** a topic bounced machine-to-machine escalate-flaps. **Mitigation:** the destination governor's dwell/hysteresis (in ModelSwapService, keyed on session.id) + lease TTL apply to the resumed session as to any escalation. Proven by the dwell lens test.
|
|
28
|
+
- **Risk:** a suppressed topic is re-escalated on arrival. **Mitigation:** double guard — the source files NO hint for a suppress topic, AND `swap()` re-consults the suppress pin at the destination. Proven by the suppress lens test.
|
|
29
|
+
- **Risk:** a carrier/driver/store error fails the transfer or spawn. **Mitigation:** every WS5.3 path is fire-and-forget and try/catch-guarded; the degrade is ALWAYS toward default tier (the cost-reducing, safe direction), audited where it matters. A transfer/spawn never fails because of WS5.3.
|
|
30
|
+
|
|
31
|
+
## Migration parity
|
|
32
|
+
|
|
33
|
+
- `ridesTopic: false` added to `DEFAULT_TIER_ESCALATION_CONFIG` AND carried by `normalizeTierEscalationConfig` — existing agents get the field automatically on read (config-read through the normalizer, never a literal default written to disk), so no `migrateConfig` change is needed.
|
|
34
|
+
- The CLAUDE.md bullet ships in `generateClaudeMd` (new agents) + an idempotent content-sniffed `migrateClaudeMd` additive-bullet patcher (existing agents). The section heading is UNCHANGED, so the feature-delivery-completeness `featureSections` entry + Codex/Gemini shadow markers stay green (the WS4.2 emptyState precedent: a sub-bullet into an already-tracked section needs no new entry).
|
|
35
|
+
|
|
36
|
+
## Dark-gate line-map
|
|
37
|
+
|
|
38
|
+
- UNCHANGED. `ridesTopic` is a new key on `DEFAULT_TIER_ESCALATION_CONFIG` in `ModelTierEscalation.ts` (referenced by `ConfigDefaults.ts` via the const), NOT an inline `enabled:` line in `ConfigDefaults.ts`. The dark-gate attributor reads `ConfigDefaults.ts` only and matches `enabled:` lines, so no line shifted. Verified: `node scripts/lint-dev-agent-dark-gate.js` → clean; `tests/unit/lint-dev-agent-dark-gate.test.ts` → 16/16.
|
|
39
|
+
|
|
40
|
+
## Rollback
|
|
41
|
+
|
|
42
|
+
- Set `models.tierEscalation.ridesTopic: false` (the default) → strict no-op. To fully revert: remove the EscalationHintStore + the `ridesTopic` field + the route source-capture + the carrier hint-carry + the driver + the local consume + the CLAUDE.md bullet. Dark + additive throughout.
|
|
43
|
+
|
|
44
|
+
## Tests
|
|
45
|
+
|
|
46
|
+
- `tests/unit/escalation-hint-store.test.ts` (13) — the store lifecycle, the serve-handler peek carry, and THE NAMED SAFETY INVARIANT (free-escalation-bypass + suppress + dwell lenses) driven through ModelSwapService against a synthetic governor.
|
|
47
|
+
- `tests/integration/escalation-rides-topic.test.ts` (4) — the cross-machine carry + re-admit end to end against a mock governor: ALLOW → swapped, REFUSE → default (the bypass invariant), owned-elsewhere → no re-admit, no-hint → no-op.
|
|
48
|
+
- `tests/e2e/escalation-rides-topic-lifecycle.test.ts` (3) — Phase-1 feature-is-alive on the production AgentServer init path: the store is constructed + exposed; dark default is inert; `/pool/transfer` answers the honest dark 503. tsc clean; dark-gate 16/16; no-silent-fallbacks + feature-delivery-completeness + docs-coverage green.
|
|
49
|
+
|
|
50
|
+
## Agent awareness
|
|
51
|
+
|
|
52
|
+
- An "Escalation rides a moved topic (WS5.3)" bullet extends the Model-Tier Escalation section in both `generateClaudeMd` and `migrateClaudeMd` (with a Codex/Gemini-parity shadow marker already covering the parent section). <!-- tracked: ws53-escalation-rides-topic -->
|
package/upgrades/1.3.533.md
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
# Upgrade Guide — vNEXT
|
|
2
|
-
|
|
3
|
-
<!-- assembled-by: assemble-next-md -->
|
|
4
|
-
<!-- bump: patch -->
|
|
5
|
-
|
|
6
|
-
## What Changed
|
|
7
|
-
|
|
8
|
-
The session reaper's shared guard (`ReapGuard`) had two halves that disagreed about a *stale* open commitment. The KILL decision (`evaluate()`) already treats a commitment as abandoned after 8h of topic silence and lets the idle session be reaped. But the RESUME-eligibility decision (`workEvidence()`) counted **any** open commitment as proof of interrupted work — with no staleness gate. So an idle session was killed (commitment stale ⇒ reap) and immediately revived (commitment exists ⇒ resume-eligible), in an endless loop.
|
|
9
|
-
|
|
10
|
-
This patch applies the **same 8h staleness gate** to the resume-eligibility probe that the kill decision already uses, so the two halves agree: a stale commitment neither keeps a session alive nor revives it, and a fresh one keeps the session alive so it never needs reviving. Strictly safer — it can only ever revive *less*, never more. Genuine interrupted work (a live build, an active sub-agent, a pending injection, a running process) is untouched.
|
|
11
|
-
|
|
12
|
-
## What to Tell Your User
|
|
13
|
-
|
|
14
|
-
Sessions that finished their work but still had an old, untouched promise on the books were being killed and revived over and over. That loop is fixed. You'll see fewer "🪦 your session was shut down — a restart is queued" notices on topics where nothing was actually unfinished. Promises still get followed up on by the commitment system — that part is unchanged.
|
|
15
|
-
|
|
16
|
-
## Summary of New Capabilities
|
|
17
|
-
|
|
18
|
-
No new capability — this is a bugfix. It removes a spurious session kill→revive loop driven by stale (long-untouched) open commitments by aligning the reaper guard's resume-eligibility decision with its existing kill-decision staleness rule.
|
|
19
|
-
|
|
20
|
-
## Evidence
|
|
21
|
-
|
|
22
|
-
- Live `logs/reap-log.jsonl` (2026-06-13): **13** age-limit reaps with `midWork=true`, every one carrying solely `workEvidence=[open-commitment]`, across 6 topics — and corresponding repeated `reason=age-limit` respawn entries in the resume queue (several doubled per topic). The loop, captured.
|
|
23
|
-
- Root cause confirmed in source: `ReapGuard.evaluate()` gates the open-commitment KEEP on `recentUserMessage(topicId, staleCommitmentWindowMs)`; `ReapGuard.workEvidence()` did not.
|
|
24
|
-
- New regression tests in `tests/unit/work-evidence.test.ts`: a stale commitment emits no `open-commitment` evidence (and `isMidWork`→false); a fresh one still does; an explicit `evaluate()`/`workEvidence()` consistency assertion; and the `protectOpenCommitments:false` boundary. All 22 work-evidence + 19 reap-guard unit tests green; 164 related reap/resume tests green; `tsc --noEmit` clean.
|
|
25
|
-
- Independent second-pass review (lifecycle change): **Concur** — confirmed the age-limit path uses the patched fallback, no legitimate revival is lost, downstream ResumeQueue / live-enqueue / boot-reconciliation re-enqueue (all `midWork`/`evidenceEligible`-gated) all behave correctly.
|