instar 1.3.531 → 1.3.533
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/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +16 -0
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/core/ReapGuard.d.ts.map +1 -1
- package/dist/core/ReapGuard.js +14 -1
- package/dist/core/ReapGuard.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/routes.d.ts.map +1 -1
- package/dist/server/routes.js +71 -1
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +63 -63
- package/src/scaffold/templates.ts +1 -0
- package/upgrades/1.3.532.md +30 -0
- package/upgrades/1.3.533.md +25 -0
- package/upgrades/eli16/reapguard-stale-commitment-revival-loop.md +28 -0
- package/upgrades/side-effects/reapguard-stale-commitment-revival-loop.md +82 -0
- package/upgrades/side-effects/ws51-subscription-pool-scope.md +92 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Stale-commitment kill→revive loop — Plain-English Overview
|
|
2
|
+
|
|
3
|
+
> The one-line version: a session that finished its work but had an old, untouched promise still on the books was being killed (correctly) and then revived (incorrectly) over and over, forever. This stops the revival half from firing on a stale promise.
|
|
4
|
+
|
|
5
|
+
## The problem in one breath
|
|
6
|
+
|
|
7
|
+
Today 13 idle sessions across 6 different topics were age-killed and then brought back to life, again and again, in a loop. Every single one was idle — its turn was finished, nothing was running — and the *only* reason the system revived it was that a commitment ("I'll keep you posted") was still registered on that topic. Many of those commitments were long-since done; they just were never marked delivered.
|
|
8
|
+
|
|
9
|
+
## What already exists
|
|
10
|
+
|
|
11
|
+
The reaper has one shared "is it safe to end this session?" guard with two halves:
|
|
12
|
+
|
|
13
|
+
- **The kill half** (`evaluate()`) already knows a commitment goes *stale*. If the topic has had no user message for 8 hours, the commitment is treated as abandoned and no longer keeps the session alive — so an idle session with only a stale promise is correctly reaped. (This staleness rule was added back on 2026-06-06.)
|
|
14
|
+
- **The revive half** (`workEvidence()`) decides whether a killed session had real interrupted *work* worth bringing back. It tags the session with evidence like "a build was running" or "a sub-agent was live" — and, until now, "an open commitment exists."
|
|
15
|
+
|
|
16
|
+
## What this changes
|
|
17
|
+
|
|
18
|
+
One line. The revive half now applies the **same 8-hour staleness gate** the kill half already uses. So an open commitment only counts as "interrupted work worth reviving" while the topic has had a user message within 8 hours.
|
|
19
|
+
|
|
20
|
+
The result: the two halves finally agree. A stale commitment neither keeps a session alive *nor* revives it. A *fresh* commitment keeps the session alive in the first place, so it never needs reviving. The loop's engine — kill (stale) then revive (commitment) — is gone.
|
|
21
|
+
|
|
22
|
+
## Why this is safe
|
|
23
|
+
|
|
24
|
+
It can only ever revive **less**, never more. A session that was genuinely doing work when it died still carries its real work signals (a live build, an active sub-agent, a pending message, a running process) — those are untouched, and a busy session is never age-killed in the first place. The only revivals removed are exactly the harmful ones: an idle session resurrected to "finish" a promise that was already done. Promises still get their own follow-through from the commitment system (the beacon and the overdue-commitment job) — that's the right owner for a promise, not a respawned session burning a fresh turn each time.
|
|
25
|
+
|
|
26
|
+
## What you'll notice
|
|
27
|
+
|
|
28
|
+
Fewer "🪦 your session was shut down — a restart is queued" messages on topics where nothing was actually unfinished. The looping topics (this one included) settle down. Nothing else changes.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Side-Effects Review — ReapGuard stale-commitment revival loop
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `reapguard-stale-commitment-revival-loop`
|
|
4
|
+
**Date:** `2026-06-13`
|
|
5
|
+
**Author:** `echo`
|
|
6
|
+
**Second-pass reviewer:** `general-purpose reviewer subagent (lifecycle change — required)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
`ReapGuard` (the shared, stateless "is it safe to reap this session?" guard) has two methods that read an open commitment differently. `evaluate()` (the KILL decision) only keeps a session alive on an open commitment while the topic has had a user message within `staleCommitmentWindowMs` (default 8h); past that the commitment is "abandoned" and the idle session is reaped (staleness gate added 2026-06-06). `workEvidence()` (the RESUME-eligibility decision) emitted `open-commitment` evidence for **any** active commitment, with **no** staleness gate. So the same guard killed an idle session (commitment stale ⇒ reap) and immediately tagged it resume-eligible (commitment exists ⇒ revive), looping forever.
|
|
11
|
+
|
|
12
|
+
Evidence (live `logs/reap-log.jsonl`, 2026-06-13): 13 age-limit reaps with `midWork=true`, **all** carrying solely `workEvidence=[open-commitment]`, across 6 topics (multi-channel-support, Resource Limitation Mitigation, instar evolution, Subscription & Auth, instar-exo, Topic UX). The resume queue showed repeated `reason=age-limit` respawn entries, several doubled per topic.
|
|
13
|
+
|
|
14
|
+
The fix: one line in `ReapGuard.workEvidence()` — the `open-commitment` probe now applies the identical predicate `evaluate()` uses (`protectOpenCommitments && activeCommitmentForTopic && recentUserMessage(staleCommitmentWindowMs)`). Files touched: `src/core/ReapGuard.ts` (the probe + a comment), `tests/unit/work-evidence.test.ts` (a new regression `describe` block).
|
|
15
|
+
|
|
16
|
+
## Decision-point inventory
|
|
17
|
+
|
|
18
|
+
- `ReapGuard.workEvidence()` open-commitment probe (`src/core/ReapGuard.ts`) — **modify** — narrows when `open-commitment` is emitted as work evidence to match the KEEP guard's staleness horizon. This is a SIGNAL producer (work evidence consumed by the resume queue / reap-notify), not a blocking authority.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 1. Over-block
|
|
23
|
+
|
|
24
|
+
**What legitimate inputs does this change reject that it shouldn't?**
|
|
25
|
+
|
|
26
|
+
This change narrows a SIGNAL (work evidence), not a block/allow gate. The "over-block" analogue is: does it now suppress `open-commitment` evidence for a session that genuinely had interrupted work? No. To be age-killed at all, a session must be idle-at-prompt with no live processes (the age-gate's `ageGateTrulyIdle` check) — a genuinely-busy session is deferred, never killed. And by the time `workEvidence()` runs as the fallback, `blockedReason()` has already returned null, meaning the commitment was already judged stale (no user message in 8h). A session with real interrupted work carries OTHER evidence (`structural-long-work`, `active-subagent`, `pending-injection`, `active-process`, `main-process-active`) which are untouched. A commitment touched within 8h still emits `open-commitment` (covered by the "FRESH commitment" test). No legitimate revival is lost.
|
|
27
|
+
|
|
28
|
+
## 2. Under-block
|
|
29
|
+
|
|
30
|
+
**What failure modes does this still miss?**
|
|
31
|
+
|
|
32
|
+
The change does not, by itself, drain the 279 already-`pending` commitments that fuel the loop — but it makes them HARMLESS for revival (a stale one no longer revives). A separate, careful runtime hygiene pass to mark genuinely-delivered commitments is tracked: <!-- tracked: topic-18423 --> (operator-visible follow-up on the live machine; not a code deferral). It also does not change the age-limit reaper killing idle sessions — that is correct behavior and out of this fix's scope by design (the loop was the *revival* half, not the kill).
|
|
33
|
+
|
|
34
|
+
## 3. Level-of-abstraction fit
|
|
35
|
+
|
|
36
|
+
**Is this at the right layer?**
|
|
37
|
+
|
|
38
|
+
Yes. `ReapGuard` is the single shared guard both the reaper and the terminate authority consult; aligning its two methods at that one place fixes every killer that uses the `workEvidence()` fallback (the age-limit path does). Putting the staleness logic anywhere downstream (resume queue, reap-notify) would patch one consumer and leave the evidence itself wrong for the others. The correct owner of *promise* follow-through (the commitment beacon / overdue-commitment job) is a different subsystem and is intentionally left to do its job — this fix stops the resume queue from double-covering it on a stale signal.
|
|
39
|
+
|
|
40
|
+
## 4. Signal vs authority compliance
|
|
41
|
+
|
|
42
|
+
**Does this hold blocking authority with brittle logic, or produce a signal that feeds a smart gate?** (ref: `docs/signal-vs-authority.md`)
|
|
43
|
+
|
|
44
|
+
Compliant. `workEvidence()` is a pure SIGNAL producer — observe-only evidence collection. It holds no blocking authority (the KILL authority is `evaluate()`/`blockedReason()`, unchanged). This change makes the existing signal MORE accurate by removing a false-positive; it adds no new brittle check and no new authority.
|
|
45
|
+
|
|
46
|
+
## 5. Interactions
|
|
47
|
+
|
|
48
|
+
**Does it shadow another check, get shadowed, double-fire, race with cleanup?**
|
|
49
|
+
|
|
50
|
+
The two `ReapGuard` methods now use one identical predicate, removing the contradiction that WAS the bug. No race: `workEvidence()` is only collected after `blockedReason()` returned null (a non-null keep returns early at terminateSession before evidence is gathered), so the two never disagree on the same live state. Downstream consumers behave correctly: dropping the sole `open-commitment` signal flips `isMidWork`→false and `evidenceEligible`→false, which correctly prevents (a) `ResumeQueue` enqueue (gated on `evidenceEligible`), (b) the server's live enqueue (gated on `midWork===true`), and (c) boot-reconciliation re-enqueue from the reap-log (also `midWork===true`). The reaper's own idle path supplies authoritative `workEvidence:[]` and is unaffected (it was never the loop's source).
|
|
51
|
+
|
|
52
|
+
## 6. External surfaces
|
|
53
|
+
|
|
54
|
+
**Does it change anything visible to other agents/users/systems?**
|
|
55
|
+
|
|
56
|
+
User-visible: fewer "your session was shut down — a restart is queued" notices on topics with no genuinely-unfinished work; the looping topics settle. Reap-log entries for these reaps now record `midWork:false` (honest — they were idle reaps, not interrupted work). No agent-to-agent or cross-system surface. No new timing/runtime dependency — it reuses the same in-memory `recentUserMessage` dep `evaluate()` already calls.
|
|
57
|
+
|
|
58
|
+
## 7. Multi-machine posture (Cross-Machine Coherence)
|
|
59
|
+
|
|
60
|
+
**Machine-local BY DESIGN.** `ReapGuard` evaluates the LOCAL machine's sessions against that machine's local topic-state deps (topic binding, recent-message, active-commitment). Session reaping is inherently a per-machine operation — a machine reaps its own sessions — so there is no replication path to add and none is wanted. The commitment registry that backs `activeCommitmentForTopic` follows its own (separate) cross-machine story; this change reads it identically to how `evaluate()` already does, so it introduces no new multi-machine assumption. No user-facing notice voice, durable-state-on-transfer, or generated-URL concern applies.
|
|
61
|
+
|
|
62
|
+
## 8. Rollback cost
|
|
63
|
+
|
|
64
|
+
Cheap. Pure code change, no migration, no persisted-state shape change. Back-out = revert the one-line probe to its prior form (`upgrades/eli16/...` + git revert) and ship a patch. The reap-log entries already written are inert historical records. Config knob `staleCommitmentWindowMs: Infinity` independently restores the always-protect behavior for BOTH methods consistently (now that they share the predicate) without a code change, if a site wants the old revival behavior back immediately.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Second-pass review
|
|
69
|
+
|
|
70
|
+
**Reviewer:** independent general-purpose reviewer subagent (required: change touches session lifecycle kill/recovery + a "guard").
|
|
71
|
+
|
|
72
|
+
**VERDICT: Concur with the review.**
|
|
73
|
+
|
|
74
|
+
The reviewer independently audited `ReapGuard.evaluate()`/`workEvidence()`, the `SessionManager.terminateSession` evidence path (lines 825–925), and the new tests, pressure-testing all five risk dimensions:
|
|
75
|
+
|
|
76
|
+
- **A (over-block):** could not construct a genuinely-interrupted session left un-revived; busy sessions never reach the age-kill, and real work carries other evidence.
|
|
77
|
+
- **B (race/consistency):** the two methods now use the identical predicate triple; the benign `recent-user-message`/`open-commitment` independence in `workEvidence()` cannot resurrect the loop (for a stale session both windows are false; workEvidence is recomputed from the same live deps regardless of WHY evaluate cleared).
|
|
78
|
+
- **C (killer-supplied evidence):** grepped every killer supplying explicit `opts.workEvidence` — only the idle-reaper (authoritative `[]`), the operator force-resume (`['active-build-or-autonomous-run']`), and tests. The age-limit path supplies none → uses the patched fallback. The fix reaches the path that actually fired.
|
|
79
|
+
- **D (config edges):** `protectOpenCommitments:false` and `staleCommitmentWindowMs:Infinity` now behave consistently across both methods.
|
|
80
|
+
- **E (downstream):** correctly prevents ResumeQueue enqueue, server live enqueue, AND boot-reconciliation re-enqueue (all `midWork===true`/`evidenceEligible` gated); ReapNotifier reports the idle reap honestly.
|
|
81
|
+
|
|
82
|
+
No concern raised; no design iteration required.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Side-Effects Review — WS5.1 (subscription-pool pool-scope visibility)
|
|
2
|
+
|
|
3
|
+
## Summary
|
|
4
|
+
|
|
5
|
+
Adds a read-side, additive `scope=pool` branch to `GET /subscription-pool` — the operator's
|
|
6
|
+
ONE view of "how much quota is left across ALL my machines / accounts." It mirrors the merged
|
|
7
|
+
WS4.1 `GET /sessions?scope=pool` fan-out exactly: fan out to every online mesh peer's PLAIN
|
|
8
|
+
`/subscription-pool` (no recursion), tag each account with the machine that holds it, and merge
|
|
9
|
+
into a dark-peer-tolerant object with a classified `pool.failed` list. No replication, no PII
|
|
10
|
+
machinery, no HLC, no new config flag. The placement tie-breaker half is DEFERRED (CMT-1416).
|
|
11
|
+
|
|
12
|
+
## Files changed
|
|
13
|
+
|
|
14
|
+
- **`src/server/routes.ts`** — the `GET /subscription-pool` handler gains an
|
|
15
|
+
`if (req.query.scope === 'pool')` branch (registered BEFORE the plain return). Self accounts
|
|
16
|
+
tagged `remote:false` + machine identity; a `Promise.all` fan-out over `resolvePeerUrls()`
|
|
17
|
+
fetches each peer's PLAIN route carrying THIS machine's Bearer with a 5s timeout; remote
|
|
18
|
+
accounts tagged `remote:true` + machineId/machineNickname; a non-OK/down/slow/unauth peer
|
|
19
|
+
pushes a NORMALIZED `pool.failed` row (`unauthorized`/`error`/`timeout`/`unreachable`). The
|
|
20
|
+
new peer-fetch catch is tagged `// @silent-fallback-ok`. The handler signature changed from
|
|
21
|
+
sync `(_req, res)` to `async (req, res)` (the fan-out awaits). The plain (no-scope) path is
|
|
22
|
+
byte-identical.
|
|
23
|
+
- **`src/scaffold/templates.ts`** — a "Quota across ALL my machines (pool-scope read)" bullet
|
|
24
|
+
on the Subscription Pool section in `generateClaudeMd()` (new agents).
|
|
25
|
+
- **`src/core/PostUpdateMigrator.ts`** — the same bullet added to the section-install template
|
|
26
|
+
AND an idempotent, content-sniffed additive-bullet patcher (`!content.includes('Quota across
|
|
27
|
+
ALL my machines')`, anchored on the "poll all now" line) for existing agents that already
|
|
28
|
+
carry the section. Mirrors the existing proactive-swap bullet patcher.
|
|
29
|
+
- **Tests**: `tests/unit/subscription-pool-scope.test.ts` (merge/tag/classify + 3 adversarial
|
|
30
|
+
lenses), `tests/integration/subscription-pool-scope.test.ts` (real peer + 401 peer + dead
|
|
31
|
+
port), `tests/e2e/subscription-pool-scope-lifecycle.test.ts` (feature-is-alive on the
|
|
32
|
+
production AgentServer path).
|
|
33
|
+
- **Spec + ELI16 + release fragment**: `docs/specs/ws51-subscription-pool-scope.md` +
|
|
34
|
+
`.eli16.md` + `upgrades/next/ws51-subscription-pool-scope.md`.
|
|
35
|
+
|
|
36
|
+
## Signal vs. Authority
|
|
37
|
+
|
|
38
|
+
This surface is a pure READ — it reports state, it never mutates or gates anything. The
|
|
39
|
+
fan-out carries THIS machine's own Bearer (its own authority, the same as the sessions route),
|
|
40
|
+
never a caller-supplied token (auth-boundary lens, tested). A peer that is down/slow/unauth is
|
|
41
|
+
the DESIGNED tolerant degrade reported up-stack in `pool.failed` (no-silent-fallbacks: the
|
|
42
|
+
catch is tagged `@silent-fallback-ok`); it never blocks, never 500s, never silently omits a
|
|
43
|
+
machine. The placement DECISION is untouched (the tie-breaker is deferred), so nothing in this
|
|
44
|
+
slice can move a live session.
|
|
45
|
+
|
|
46
|
+
## Framework generality
|
|
47
|
+
|
|
48
|
+
The capability is framework-agnostic — it reads the subscription pool registry + the mesh peer
|
|
49
|
+
set, neither of which is Claude-specific. A Codex/Gemini agent running a multi-account pool
|
|
50
|
+
gets the same pool-scope view. The CLAUDE.md awareness rides the existing Subscription Pool
|
|
51
|
+
featureSection whose shadow markers already mirror it to AGENTS.md / GEMINI.md (the section
|
|
52
|
+
heading is unchanged, so Codex/Gemini parity is preserved with no marker edit).
|
|
53
|
+
|
|
54
|
+
## Operator surface quality
|
|
55
|
+
|
|
56
|
+
The operator never types a curl. The conversational trigger is "how much quota is left across
|
|
57
|
+
ALL my machines?" — the CLAUDE.md bullet (new + existing agents) teaches the agent to reach for
|
|
58
|
+
`GET /subscription-pool?scope=pool` and to explain a dark peer honestly (a classified
|
|
59
|
+
`pool.failed` row, never a silent omission). The response is plain JSON the agent narrates; the
|
|
60
|
+
normalized failure reasons (`timeout`/`unreachable`/`unauthorized`/`error`) are
|
|
61
|
+
operator-legible and carry no internal URL or token.
|
|
62
|
+
|
|
63
|
+
## Blast radius / risk
|
|
64
|
+
|
|
65
|
+
- **Additive route-branch, no config flag.** The dark-gate line-map is UNCHANGED (verified
|
|
66
|
+
16/16 green without editing the EXPECTED map) — no ConfigDefaults enabled-path was touched.
|
|
67
|
+
- **Back-compat preserved.** The plain `GET /subscription-pool` (no `scope`) returns the exact
|
|
68
|
+
`{ enabled, count, accounts }` shape as before — verified by the existing
|
|
69
|
+
`subscription-pool-routes` + `subscription-pool` unit suites staying green and an integration
|
|
70
|
+
assertion that the plain route has no `pool`/`scope` keys.
|
|
71
|
+
- **Single-machine = strict no-op superset.** No peers / no `resolvePeerUrls` → the self-only
|
|
72
|
+
view tagged `scope:'pool'` with empty `pool.failed`. An unwired pool → `enabled:false`,
|
|
73
|
+
`accounts:[]`, still `scope:'pool'`.
|
|
74
|
+
- **No amplification.** The peer fetch hits the PLAIN route (no `scope=pool`), `Promise.all` is
|
|
75
|
+
bounded by the live peer set, each fetch has its own 5s timeout — no fan-out storm on an
|
|
76
|
+
N-machine pool.
|
|
77
|
+
|
|
78
|
+
## Deferred / tracked
|
|
79
|
+
|
|
80
|
+
- The placement tie-breaker (prefer the machine with more account-pool headroom on an
|
|
81
|
+
otherwise-equal tie) is DEFERRED as `<!-- tracked: CMT-1416 -->`. `MachineCapacity` carries
|
|
82
|
+
`quotaState.blocked` only — no per-account remaining%; plumbing aggregate account-pool
|
|
83
|
+
headroom through the capacity heartbeat is larger than a clean small slice. The pool-scope
|
|
84
|
+
READ ships alone. WS5.2 (account follow-me) / WS5.3 (escalation rides the topic) are separate
|
|
85
|
+
surfaces.
|
|
86
|
+
|
|
87
|
+
## Rollback
|
|
88
|
+
|
|
89
|
+
Revert the single `feat` commit. The change is purely additive (a new query-branch + two
|
|
90
|
+
awareness bullets); reverting restores the prior sync handler and the prior CLAUDE.md template
|
|
91
|
+
with no migration cleanup needed (the additive migrator patcher is content-sniffed and simply
|
|
92
|
+
stops running once the source no longer carries the bullet template).
|