fix(session): close the review gaps the boundary opened

- `SessionSummary.updatedAt`'s wire doc still said "Persisted file mtime",
  which stopped being true for attached sessions.
- The core invariant let `session/inherited` fall through the merge-extensible
  default. It is core-owned, so it gets an explicit case; an unbalanced seed
  legally places it inside an open turn, which the relation permits.
- The Agent Note claimed the boundary reaches disk via `live.pending`/
  `scheduleDrain`. Verified false: the constructor append precedes `enter()`,
  so it never publishes on `session/event` and rides the creation seed instead.
  Attaching is therefore a write where none happened before — recorded, since
  only `load()` stays a pure read.
- The deferred-index proposal asserted this change documented the cold-mtime
  skew on `dsh-host-apiproxy`. It did not; the README entry now exists.
- `firstLiveSeq`'s firehose gap runs through its own seq, not below it.
- The boundary is not always at `firstLiveSeq` (the idempotence guard), so
  consumers scan for the last one.
- `lastActivityTime` excludes by type, so a pickup time still leaks onto a
  synthetic closer when a boundary ends an open turn. Documented.
- Pin the fork claim end-to-end: a child inherits a still-running parent's
  open bracket below its own boundary, while the parent has none. Fails if the
  write moves back to the load path.
- Fix the telemetry title that contradicted its own assertions.

The `/status` call site cannot be pinned the way the other two are: the
command appends its own `command/run` before rendering, so the boundary is
never the log tail there. Its fixture now at least renders over a
boundary-bearing log.
This commit is contained in:
Hypatia May
2026-07-30 13:59:08 +08:00
parent a00786362f
commit 39ebd8f745
26 changed files with 115 additions and 23 deletions

View File

@@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
Source: [`packages/core/session/src/index.ts:706`](../../packages/core/session/src/index.ts)
Source: [`packages/core/session/src/index.ts:710`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/core-data-structures/session.md
session.md: 25fb5471e600541a3a2d5d2b67083f5b443dcd52
session.zh.md: 9cf8c61d3c2ca27c4cdc6adf5804e59dd73b5f5e
session.md: 03a1a3ee3350009e2f26cc52ff013643a04b5bd9
session.zh.md: d1c676f1c9297582d9e80ed5d15af78c7ef68e52

View File

@@ -97,6 +97,10 @@ interface SessionEventMap {
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
*
* Locate the LAST one rather than reading `firstLiveSeq`: a seed already
* ending in a boundary is not re-marked, so reopening an untouched session
* does not grow its log per pickup.
*
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -346,6 +350,10 @@ declare class Session {
* `session/inherited` event at this seq, which is what a consumer reading
* STORED history reads. Prefer this field in-process — it is exact before
* the marker's write reaches storage.
*
* The marker is appended before the store attaches, so when one exists the
* event AT this seq did not publish either: the firehose gap runs through
* `firstLiveSeq`, not just below it.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);

View File

@@ -97,6 +97,10 @@ interface SessionEventMap {
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
*
* Locate the LAST one rather than reading `firstLiveSeq`: a seed already
* ending in a boundary is not re-marked, so reopening an untouched session
* does not grow its log per pickup.
*
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -348,6 +352,10 @@ declare class Session {
* `session/inherited` event at this seq, which is what a consumer reading
* STORED history reads. Prefer this field in-process — it is exact before
* the marker's write reaches storage.
*
* The marker is appended before the store attaches, so when one exists the
* event AT this seq did not publish either: the firehose gap runs through
* `firstLiveSeq`, not just below it.
*/
readonly firstLiveSeq: number;
constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);

View File

@@ -78,7 +78,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
}[T]
```
Sources: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:277`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:306`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts)
Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:281`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts)
## Events
@@ -413,6 +413,10 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
* and no writer in this lifecycle produced it. Payload is empty — position
* and `time` carry the meaning.
*
* Locate the LAST one rather than reading `firstLiveSeq`: a seed already
* ending in a boundary is not re-marked, so reopening an untouched session
* does not grow its log per pickup.
*
* An owner of a standalone open/close bracket (`compact/start` …
* `compact/end`) reads it because inherited history and live work are
* otherwise byte-identical: an unmatched opening marker below the boundary
@@ -423,7 +427,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
'session/inherited': Record<string, never>
```
Source: [`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts)
Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts)
#### `session/title` — log-only