instar 1.3.238 → 1.3.239
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 +6 -1
- package/dist/commands/server.js.map +1 -1
- package/dist/messaging/SpawnRequestManager.d.ts +4 -1
- package/dist/messaging/SpawnRequestManager.d.ts.map +1 -1
- package/dist/messaging/SpawnRequestManager.js +8 -1
- package/dist/messaging/SpawnRequestManager.js.map +1 -1
- package/dist/scheduler/MentorOnboardingRunner.d.ts +10 -1
- package/dist/scheduler/MentorOnboardingRunner.d.ts.map +1 -1
- package/dist/scheduler/MentorOnboardingRunner.js +1 -0
- package/dist/scheduler/MentorOnboardingRunner.js.map +1 -1
- package/dist/scheduler/MentorOnboardingTick.d.ts +25 -0
- package/dist/scheduler/MentorOnboardingTick.d.ts.map +1 -1
- package/dist/scheduler/MentorOnboardingTick.js +13 -0
- package/dist/scheduler/MentorOnboardingTick.js.map +1 -1
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +31 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/threadline/ThreadResumeMap.d.ts +8 -0
- package/dist/threadline/ThreadResumeMap.d.ts.map +1 -1
- package/dist/threadline/ThreadResumeMap.js +31 -0
- package/dist/threadline/ThreadResumeMap.js.map +1 -1
- package/dist/threadline/WarmSessionPool.d.ts +62 -0
- package/dist/threadline/WarmSessionPool.d.ts.map +1 -0
- package/dist/threadline/WarmSessionPool.js +116 -0
- package/dist/threadline/WarmSessionPool.js.map +1 -0
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +3 -3
- package/upgrades/1.3.239.md +96 -0
- package/upgrades/mentor-tick-cycle-capture.eli16.md +18 -0
- package/upgrades/side-effects/mentor-tick-cycle-capture.md +39 -0
- package/upgrades/side-effects/threadline-a2a-live-inject.md +60 -0
- package/upgrades/threadline-a2a-live-inject.eli16.md +30 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WarmSessionPool — Layer 2 core (THREADLINE-A2A-COHERENCE-SPEC).
|
|
3
|
+
*
|
|
4
|
+
* Keeps an agent-to-agent session "warm" for a TTL so rapid follow-ups inject into the running
|
|
5
|
+
* session (via the existing live-injection path) instead of respawning each message. The
|
|
6
|
+
* operator-confirmed model is **per-peer** with a **global + per-peer cap** and **TTL + LRU
|
|
7
|
+
* eviction**, so an inbound flood (many threads / one chatty peer) can't pin unbounded live
|
|
8
|
+
* Claude processes. This is the pure registry + eviction policy; the server wires it to the real
|
|
9
|
+
* session lifecycle (keep-alive, inject, kill-on-evict) and the SessionReaper (warm sessions are
|
|
10
|
+
* evict-eligible under resource pressure — eviction is lossless because the next message falls
|
|
11
|
+
* back to Layer 1 resume).
|
|
12
|
+
*
|
|
13
|
+
* Pure + clock-injected → fully testable. Eviction is reported (the caller kills the tmux/Claude
|
|
14
|
+
* session); the pool itself holds no I/O.
|
|
15
|
+
*/
|
|
16
|
+
export class WarmSessionPool {
|
|
17
|
+
config;
|
|
18
|
+
now;
|
|
19
|
+
byThread = new Map();
|
|
20
|
+
constructor(config, now = () => Date.now()) {
|
|
21
|
+
this.config = config;
|
|
22
|
+
this.now = now;
|
|
23
|
+
}
|
|
24
|
+
size() {
|
|
25
|
+
return this.byThread.size;
|
|
26
|
+
}
|
|
27
|
+
peerCount(peerId) {
|
|
28
|
+
let n = 0;
|
|
29
|
+
for (const r of this.byThread.values())
|
|
30
|
+
if (r.peerId === peerId)
|
|
31
|
+
n++;
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
/** LRU among a predicate set (oldest lastUsedAt first). */
|
|
35
|
+
lru(filter) {
|
|
36
|
+
const rs = [...this.byThread.values()].filter(r => (filter ? filter(r) : true));
|
|
37
|
+
rs.sort((a, b) => a.lastUsedAt - b.lastUsedAt);
|
|
38
|
+
return rs;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Admit (or refresh) a warm session for a thread. Returns the records that must be EVICTED
|
|
42
|
+
* (killed by the caller) to honor the caps — LRU first, per-peer cap before global cap. An
|
|
43
|
+
* existing thread is refreshed in place (no eviction).
|
|
44
|
+
*/
|
|
45
|
+
admit(input) {
|
|
46
|
+
const now = this.now();
|
|
47
|
+
const existing = this.byThread.get(input.threadId);
|
|
48
|
+
if (existing) {
|
|
49
|
+
existing.lastUsedAt = now;
|
|
50
|
+
existing.sessionName = input.sessionName;
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
const evicted = [];
|
|
54
|
+
// Per-peer cap: evict the peer's LRU until under the cap (leaving room for the new one).
|
|
55
|
+
while (this.peerCount(input.peerId) >= this.config.perPeerCap) {
|
|
56
|
+
const victim = this.lru(r => r.peerId === input.peerId)[0];
|
|
57
|
+
if (!victim)
|
|
58
|
+
break;
|
|
59
|
+
this.byThread.delete(victim.threadId);
|
|
60
|
+
evicted.push(victim);
|
|
61
|
+
}
|
|
62
|
+
// Global cap: evict the global LRU until under the cap.
|
|
63
|
+
while (this.byThread.size >= this.config.globalCap) {
|
|
64
|
+
const victim = this.lru()[0];
|
|
65
|
+
if (!victim)
|
|
66
|
+
break;
|
|
67
|
+
this.byThread.delete(victim.threadId);
|
|
68
|
+
evicted.push(victim);
|
|
69
|
+
}
|
|
70
|
+
this.byThread.set(input.threadId, {
|
|
71
|
+
threadId: input.threadId,
|
|
72
|
+
peerId: input.peerId,
|
|
73
|
+
sessionName: input.sessionName,
|
|
74
|
+
createdAt: now,
|
|
75
|
+
lastUsedAt: now,
|
|
76
|
+
});
|
|
77
|
+
return evicted;
|
|
78
|
+
}
|
|
79
|
+
/** Mark a thread's warm session used (LRU refresh). */
|
|
80
|
+
touch(threadId) {
|
|
81
|
+
const r = this.byThread.get(threadId);
|
|
82
|
+
if (r)
|
|
83
|
+
r.lastUsedAt = this.now();
|
|
84
|
+
}
|
|
85
|
+
/** The warm session for a thread, if present and not past its idle TTL; else undefined. */
|
|
86
|
+
get(threadId) {
|
|
87
|
+
const r = this.byThread.get(threadId);
|
|
88
|
+
if (!r)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (this.now() - r.lastUsedAt >= this.config.ttlMs)
|
|
91
|
+
return undefined;
|
|
92
|
+
return r;
|
|
93
|
+
}
|
|
94
|
+
remove(threadId) {
|
|
95
|
+
const r = this.byThread.get(threadId);
|
|
96
|
+
if (r)
|
|
97
|
+
this.byThread.delete(threadId);
|
|
98
|
+
return r;
|
|
99
|
+
}
|
|
100
|
+
/** Reap warm sessions idle past the TTL. Returns the evicted records (caller kills them). */
|
|
101
|
+
reapExpired() {
|
|
102
|
+
const now = this.now();
|
|
103
|
+
const expired = [...this.byThread.values()].filter(r => now - r.lastUsedAt >= this.config.ttlMs);
|
|
104
|
+
for (const r of expired)
|
|
105
|
+
this.byThread.delete(r.threadId);
|
|
106
|
+
return expired;
|
|
107
|
+
}
|
|
108
|
+
/** Reap up to n warm sessions under resource pressure (LRU first). Returns evicted records. */
|
|
109
|
+
reapUnderPressure(n) {
|
|
110
|
+
const victims = this.lru().slice(0, Math.max(0, n));
|
|
111
|
+
for (const r of victims)
|
|
112
|
+
this.byThread.delete(r.threadId);
|
|
113
|
+
return victims;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=WarmSessionPool.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"WarmSessionPool.js","sourceRoot":"","sources":["../../src/threadline/WarmSessionPool.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./builtin-manifest.schema.json",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
|
-
"generatedAt": "2026-06-04T04:
|
|
5
|
-
"instarVersion": "1.3.
|
|
4
|
+
"generatedAt": "2026-06-04T04:41:03.490Z",
|
|
5
|
+
"instarVersion": "1.3.239",
|
|
6
6
|
"entryCount": 198,
|
|
7
7
|
"entries": {
|
|
8
8
|
"hook:session-start": {
|
|
@@ -1498,7 +1498,7 @@
|
|
|
1498
1498
|
"type": "subsystem",
|
|
1499
1499
|
"domain": "server",
|
|
1500
1500
|
"sourcePath": "src/server/AgentServer.ts",
|
|
1501
|
-
"contentHash": "
|
|
1501
|
+
"contentHash": "e72905d8a8de5d22c7cedb5c599f9341f61ac9139f65f32bde7011a1c5202ef7",
|
|
1502
1502
|
"since": "2025-01-01"
|
|
1503
1503
|
},
|
|
1504
1504
|
"subsystem:session-manager": {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Upgrade Guide — vNEXT
|
|
2
|
+
|
|
3
|
+
<!-- assembled-by: assemble-next-md -->
|
|
4
|
+
<!-- bump: patch -->
|
|
5
|
+
|
|
6
|
+
## What Changed
|
|
7
|
+
|
|
8
|
+
The automated mentor-onboarding tick now records a keystone
|
|
9
|
+
`mentor-mentee-differential` apprenticeship **cycle** each run (in addition to the
|
|
10
|
+
existing per-finding ledger capture) — the structural version of the manual
|
|
11
|
+
differential-oversight loop. The mentee's Stage-A transcript becomes the cycle's
|
|
12
|
+
`menteeOutput`; the forensics findings become the `overseerDifferential`.
|
|
13
|
+
|
|
14
|
+
It is wired via a new optional `recordCycle` injected into `runMentorTick` and
|
|
15
|
+
`MentorOnboardingRunner`, and a new `mentor.apprenticeshipInstanceId` config field.
|
|
16
|
+
`AgentServer.buildMentorRunner` resolves it to the ApprenticeshipCycleStore only
|
|
17
|
+
when that instance id is set — so it is **opt-in and a no-op by default**
|
|
18
|
+
(back-compat). This makes the automated loop fire the keystone axis the program's
|
|
19
|
+
role-coverage drift-warning watches, instead of only the easy finding-capture path.
|
|
20
|
+
|
|
21
|
+
Fixed the production **agent-to-agent (Threadline) continuity break**: a live
|
|
22
|
+
Echo↔Dawn round-trip proved that every peer-to-peer follow-up message was being
|
|
23
|
+
handled by a fresh, memoryless session (2 Spawned / 0 Resumed on a single thread)
|
|
24
|
+
instead of the session already in the conversation — the "crowd of fragments
|
|
25
|
+
wearing my name" incoherence.
|
|
26
|
+
|
|
27
|
+
Root cause funnels through one guard. `ThreadResumeMap.get()`
|
|
28
|
+
(`src/threadline/ThreadResumeMap.ts`) nulled the entire resume entry whenever the
|
|
29
|
+
session transcript JSONL was absent — and for relay/pipe-spawned A2A sessions the
|
|
30
|
+
uuid is a spawn-time placeholder that never gets upgraded, so the guard discarded
|
|
31
|
+
the entry (including the still-valid live `sessionName`) on every follow-up. With
|
|
32
|
+
a null entry, `ThreadlineRouter.handleInboundMessage` skips the already-built
|
|
33
|
+
`tryInjectIntoLiveSession` + `resumeThread` machinery and cold-spawns.
|
|
34
|
+
|
|
35
|
+
The fix has two parts. **(1)** When the transcript is absent, `get()` now returns
|
|
36
|
+
the entry anyway **if its tmux session is still alive** (new protected
|
|
37
|
+
`sessionAlive()`, exact-match `tmux has-session -t =name`), so the follow-up routes
|
|
38
|
+
into the running session (live-inject, or a resume that carries the conversation
|
|
39
|
+
history) — no transcript needed. Dead session + no transcript still nulls (resume
|
|
40
|
+
guard intact); topic-bound + pinned exemptions unchanged. **(2)** Part 1 was inert
|
|
41
|
+
without the real session name: `spawnNewThread` recorded the resume entry's
|
|
42
|
+
`sessionName` from the spawn result, but the `spawnSession` callback only returned
|
|
43
|
+
the bare instar session id, so a useless fallback name was stored that
|
|
44
|
+
`sessionAlive()`/`getBySessionName` could never match. The callback contract now
|
|
45
|
+
returns `string | { sessionId, tmuxSession }` (back-compatible);
|
|
46
|
+
`SpawnRequestManager.evaluate` forwards the real tmux name through to the entry. Also lands the `WarmSessionPool`
|
|
47
|
+
(`src/threadline/WarmSessionPool.ts`) per-peer warm-session registry (caps + TTL +
|
|
48
|
+
LRU) — the tested foundation for the follow-up that keeps A2A sessions warm so
|
|
49
|
+
follow-ups inject even after the first reply completes.
|
|
50
|
+
|
|
51
|
+
1511 threadline unit tests pass; tsc clean. Tier-1 (ELI16 +
|
|
52
|
+
side-effects artifact).
|
|
53
|
+
|
|
54
|
+
## What to Tell Your User
|
|
55
|
+
|
|
56
|
+
Nothing changes by default. When I'm running an automated mentorship for a
|
|
57
|
+
configured apprenticeship instance, the loop now records proper differential cycles
|
|
58
|
+
on its own — the same keystone cycles I capture by hand — so the program can see
|
|
59
|
+
the real mentor↔mentee work, not just logged findings.
|
|
60
|
+
|
|
61
|
+
When another agent talks to me across several messages in a row, I now stay one
|
|
62
|
+
coherent conversation instead of answering each message as if I'd never seen the
|
|
63
|
+
earlier ones. This is the foundation the agent-to-agent collaboration (and the
|
|
64
|
+
upcoming feedback-process work) relies on. It's an internal robustness fix — there
|
|
65
|
+
is nothing to turn on, and it doesn't change how I talk to you directly.
|
|
66
|
+
|
|
67
|
+
## Summary of New Capabilities
|
|
68
|
+
|
|
69
|
+
- **Threadline A2A continuity:** agent-to-agent threads stay coherent across rapid
|
|
70
|
+
follow-ups — a follow-up now injects into the session already handling the thread
|
|
71
|
+
instead of cold-spawning a fresh, memoryless one.
|
|
72
|
+
- **WarmSessionPool foundation:** a per-peer warm-session registry (caps + TTL +
|
|
73
|
+
LRU) lands as the tested groundwork for keeping A2A sessions warm.
|
|
74
|
+
|
|
75
|
+
## Evidence
|
|
76
|
+
|
|
77
|
+
Reproduced on this machine, before/after.
|
|
78
|
+
|
|
79
|
+
**Before (live Echo↔Dawn round-trip, 2026-06-04 ~01:55Z):** sent Dawn one opener on
|
|
80
|
+
a fresh non-topic thread `echo-dawn-continuity-20260604`; her inbound replies
|
|
81
|
+
produced — in `logs/server.log` — `2× [relay] Spawned session`, `0× Resumed
|
|
82
|
+
session`, `1× Spawn denied: Cooldown`. The resume-map never gained an entry for the
|
|
83
|
+
thread; every follow-up cold-spawned a memoryless session (a parallel relay-spawn
|
|
84
|
+
even answered Dawn with "No previous history available").
|
|
85
|
+
|
|
86
|
+
**After (integration test against REAL tmux —
|
|
87
|
+
`tests/integration/threadline-live-inject-real-tmux.test.ts`, 2026-06-04):** with a
|
|
88
|
+
placeholder uuid (no transcript) on a non-topic thread —
|
|
89
|
+
- while a real tmux session of that name is **alive** → `get()` returns the entry,
|
|
90
|
+
so `ThreadlineRouter.tryInjectIntoLiveSession` is reachable (was: null →
|
|
91
|
+
cold-spawn). ✓
|
|
92
|
+
- after the tmux session is **killed** → `get()` returns null (resume guard intact;
|
|
93
|
+
dead session → cold-spawn). ✓
|
|
94
|
+
|
|
95
|
+
The end-to-end live Dawn round-trip showing `Injected`/`Resumed` instead of
|
|
96
|
+
`Spawned` runs post-deploy (Echo auto-updates on this release).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Mentor tick records keystone cycles — explained simply
|
|
2
|
+
|
|
3
|
+
When the automated mentor job runs a "tick" (it watches the mentee, gathers
|
|
4
|
+
findings), it used to log those findings to one place — but it never recorded a
|
|
5
|
+
proper "cycle" of the mentorship the way the apprenticeship program tracks them.
|
|
6
|
+
|
|
7
|
+
That mattered because the program's #1 failure mode is "role drift": the easy work
|
|
8
|
+
gets logged, but the actual keystone — the mentor↔mentee differential — quietly
|
|
9
|
+
never gets exercised. The manual loop I run by hand records those keystone cycles;
|
|
10
|
+
the automated job didn't.
|
|
11
|
+
|
|
12
|
+
This change wires the automated tick to ALSO record a real
|
|
13
|
+
`mentor-mentee-differential` cycle each time: the mentee's output becomes the
|
|
14
|
+
cycle's menteeOutput, the forensics findings become the differential. It only
|
|
15
|
+
does this when an apprenticeship instance is configured (a new
|
|
16
|
+
`mentor.apprenticeshipInstanceId` setting) — so by default nothing changes; it's
|
|
17
|
+
opt-in. Now the automated loop fires the same keystone axis the manual loop does,
|
|
18
|
+
which is the structural fix for that drift.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Side-Effects Review — Mentor tick keystone cycle capture
|
|
2
|
+
|
|
3
|
+
**Slug:** `mentor-tick-cycle-capture` · **Date:** `2026-06-04` · **Author:** `echo`
|
|
4
|
+
**Second-pass reviewer:** `not required` (additive, opt-in, no-op by default)
|
|
5
|
+
|
|
6
|
+
## Summary
|
|
7
|
+
|
|
8
|
+
`runMentorTick` now (after the existing per-finding ledger capture) records a
|
|
9
|
+
`mentor-mentee-differential` apprenticeship CYCLE via an injected, optional
|
|
10
|
+
`recordCycle`. The host (`AgentServer.buildMentorRunner`) wires it to the
|
|
11
|
+
ApprenticeshipCycleStore ONLY when `mentor.apprenticeshipInstanceId` is set, so it
|
|
12
|
+
is a no-op by default. The mentee's transcript is the cycle's menteeOutput; the
|
|
13
|
+
forensics findings (mapped to titles) are the differential.
|
|
14
|
+
|
|
15
|
+
## Decision-point inventory
|
|
16
|
+
|
|
17
|
+
One new branch: record a cycle iff `recordCycle` is wired AND the transcript is
|
|
18
|
+
non-empty. Both sides covered (records on a real tick; skips on empty transcript;
|
|
19
|
+
no-op when unwired).
|
|
20
|
+
|
|
21
|
+
## 1. Over-block
|
|
22
|
+
|
|
23
|
+
Rejects nothing real. The transcript guard skips an empty/failed Stage A (not a
|
|
24
|
+
cycle) — intended. Without `apprenticeshipInstanceId`, no cycle is recorded
|
|
25
|
+
(back-compat); the existing finding-capture path is unchanged.
|
|
26
|
+
|
|
27
|
+
## 2. Under-block
|
|
28
|
+
|
|
29
|
+
It records ALL of a tick's findings as the differential without de-duping against
|
|
30
|
+
prior cycles (acceptable — each tick is a distinct cycle). The cycle's `task` is a
|
|
31
|
+
generic per-tick descriptor, not the full Stage-A prompt (kept terse on purpose).
|
|
32
|
+
cycleNumber is computed as max-existing+1 (single-writer mentor tick, no race).
|
|
33
|
+
|
|
34
|
+
## 3. Level-of-abstraction fit
|
|
35
|
+
|
|
36
|
+
Right layer: the pure tick gets an injected callback (no store dependency in the
|
|
37
|
+
pure core); the store wiring + instance resolution live in `buildMentorRunner`
|
|
38
|
+
beside the existing `capture` wiring, reading the cycle store lazily to dodge the
|
|
39
|
+
boot-order (buildMentorRunner runs before the store is constructed).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Side-Effects Review — Threadline A2A live-inject (keep resume entry when session alive)
|
|
2
|
+
|
|
3
|
+
**Version / slug:** `threadline-a2a-live-inject`
|
|
4
|
+
**Date:** `2026-06-04`
|
|
5
|
+
**Author:** `Echo (instar dev agent)`
|
|
6
|
+
**Second-pass reviewer:** `not required (Tier 1)`
|
|
7
|
+
|
|
8
|
+
## Summary of the change
|
|
9
|
+
|
|
10
|
+
`ThreadResumeMap.get()` is the lookup `ThreadlineRouter.handleInboundMessage` uses to decide whether an inbound A2A message injects into a live session, resumes with history, or cold-spawns. Its resume guard nulled the entire entry whenever the session JSONL transcript was absent (`!jsonlExists(uuid)`), for any non-topic-bound, non-pinned thread. Because relay/pipe-spawned A2A sessions never get their placeholder uuid upgraded to a real transcript id, the guard nulled every peer-to-peer thread entry — discarding the still-valid live `sessionName` along with the placeholder uuid — so every follow-up cold-spawned a memoryless session (the production continuity break proven by the live Echo↔Dawn round-trip: 2 Spawned / 0 Resumed). The fix has two parts. **(1)** `get()` adds one branch: when the transcript is absent, return the entry anyway IF its tmux session is currently alive (new protected `sessionAlive()`, exact-match `tmux has-session -t =name`), so the existing `tryInjectIntoLiveSession`/`resumeThread` path can deliver into / resume the running session. **(2)** Part 1 was a no-op without the real session name: `spawnNewThread` persisted `spawnResult.tmuxSession` as the entry's `sessionName`, but the `spawnSession` callback returned only the bare instar session id, so `tmuxSession` was always undefined and a useless fallback name (`thread-<id8>`) was stored — which `sessionAlive()` and `onSessionComplete`'s `getBySessionName` could never match. The `spawnSession` callback contract now returns `string | { sessionId, tmuxSession }`; `SpawnRequestManager.evaluate` normalizes both forms and forwards `tmuxSession`; the relay spawn impl returns the real `session.tmuxSession`. Files: `src/threadline/ThreadResumeMap.ts`, `src/messaging/SpawnRequestManager.ts`, `src/commands/server.ts`, `tests/unit/threadline/ThreadResumeMap.test.ts`, `tests/unit/threadline-fixes.test.ts`, `tests/integration/threadline-live-inject-real-tmux.test.ts`.
|
|
11
|
+
|
|
12
|
+
## Decision-point inventory
|
|
13
|
+
|
|
14
|
+
- `ThreadResumeMap.get()` resume guard (`src/threadline/ThreadResumeMap.ts`) — **modify** — when transcript absent, keep the entry if its session is alive (was: always null).
|
|
15
|
+
- `ThreadResumeMap.sessionAlive()` — **add** — protected tmux-liveness helper; only consulted on the transcript-absent branch.
|
|
16
|
+
- `SpawnRequestManager.spawnSession` callback contract (`src/messaging/SpawnRequestManager.ts`) — **modify** — return type widened to `string | { sessionId, tmuxSession }` (back-compatible); `evaluate` normalizes + forwards `tmuxSession`.
|
|
17
|
+
- Relay `spawnSession` impl (`src/commands/server.ts`) — **modify** — returns `{ sessionId, tmuxSession }` so the real tmux name reaches the resume entry.
|
|
18
|
+
|
|
19
|
+
## 1. Over-block
|
|
20
|
+
|
|
21
|
+
**What legitimate inputs does this change reject that it shouldn't?**
|
|
22
|
+
|
|
23
|
+
None — the change only *widens* what `get()` returns; it never rejects anything it previously accepted. The healthy path (real transcript exists) short-circuits before the new branch (verified by the "does NOT consult liveness when a real JSONL exists" test), so well-formed resume entries behave exactly as before. No new rejection surface.
|
|
24
|
+
|
|
25
|
+
## 2. Under-block
|
|
26
|
+
|
|
27
|
+
**What failure modes does this still miss?**
|
|
28
|
+
|
|
29
|
+
The idle-gap case: when an A2A session has fully exited AND no real transcript was captured, `get()` still returns null → cold-spawn with no history. This is unchanged by this slice and is the next slice (capture the real transcript id / resume-with-history for dead sessions). This change does not regress it; it only fixes the live-session case. Also: a stale `sessionName` whose tmux name was recycled to an unrelated session could in principle be injected into — bounded because A2A session names are unique per spawn (`msg-spawn-<timestamp>`/thread-scoped) and the delivery layer validates the target.
|
|
30
|
+
|
|
31
|
+
## 3. Level-of-abstraction fit
|
|
32
|
+
|
|
33
|
+
The fix lives in `ThreadResumeMap.get()` — the single chokepoint every router branch consults — rather than scattering liveness checks across `handleInboundMessage`. The liveness primitive (`tmux has-session -t =name`) matches the existing idiom used at `ThreadResumeMap` line ~305 and `cli.ts`. Correct layer: the storage lookup already owns the transcript-existence guard, so it should own the "but the session is alive" exception too.
|
|
34
|
+
|
|
35
|
+
## 4. Signal vs authority compliance
|
|
36
|
+
|
|
37
|
+
Not a gate/authority change. `get()` is a lookup, not an approval surface; this does not create or relax any authority. The trust gates on the inbound path (relay trust tiers, spawn evaluation) are untouched — a thread only has a resume entry because it already passed those gates on first contact.
|
|
38
|
+
|
|
39
|
+
## 5. Interactions
|
|
40
|
+
|
|
41
|
+
- `ThreadlineRouter.handleInboundMessage` (line ~554): now reaches `tryInjectIntoLiveSession` for non-topic-bound threads with a live session. That method already fails gracefully (returns null → falls through) if the session is dead, so the race window is safe.
|
|
42
|
+
- `server.ts` pipe-spawn guard (`!threadResumeMap.get(...)`): now correctly skips pipe-spawn when a live session exists — desirable.
|
|
43
|
+
- `isTopicBoundReply` check: unaffected (originTopicId still undefined for these entries).
|
|
44
|
+
- 1511 threadline unit tests pass; tsc clean.
|
|
45
|
+
|
|
46
|
+
## 6. External surfaces
|
|
47
|
+
|
|
48
|
+
No new HTTP routes, no config, no migration, no template/CLAUDE.md change. Pure internal routing behavior. No user-facing surface. `sessionAlive()` shells out to `tmux has-session` only on the (rare) transcript-absent branch — bounded subprocess cost, gated behind a failed `jsonlExists`.
|
|
49
|
+
|
|
50
|
+
## 7. Rollback cost
|
|
51
|
+
|
|
52
|
+
Trivial — single-file revert of `ThreadResumeMap.get()` + the helper. No state migration, no persisted format change, no external dependency. Behavior reverts to cold-spawn (the prior, known-broken-but-safe state).
|
|
53
|
+
|
|
54
|
+
## Conclusion
|
|
55
|
+
|
|
56
|
+
Low-risk, surgical fix that activates already-built inject machinery for the dominant rapid-fire A2A case. The only behavioral change is `get()` returning an entry it previously discarded, strictly when the session is alive. No over-block, no authority surface, trivial rollback. Idle-gap continuity is explicitly out of scope (next slice).
|
|
57
|
+
|
|
58
|
+
## Second-pass review (if required)
|
|
59
|
+
|
|
60
|
+
Not required — Tier 1 (30 LOC, single file, risk floor 1).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Why agent-to-agent conversations stopped forgetting themselves mid-thread
|
|
2
|
+
|
|
3
|
+
## The one-sentence version
|
|
4
|
+
When another agent (like Dawn) sent me several messages in a row, every message after the first was being handled by a **brand-new, memoryless copy of me** instead of the one already in the conversation. This fix lets a follow-up message land in the session that's *already talking* — so the conversation stays one coherent thread instead of a crowd of strangers wearing my name.
|
|
5
|
+
|
|
6
|
+
## Picture it
|
|
7
|
+
Imagine you're on a phone call. You say something, and instead of the person you were talking to answering, a **fresh person who has never heard of you** picks up a new phone and says "Hi, who's this?" Then you add a detail — and *another* stranger picks up. That's what was happening to every agent-to-agent thread: each incoming message spun up a new session that had zero memory of the previous ones.
|
|
8
|
+
|
|
9
|
+
The machinery to "hand the message to the person already on the call" was **already built** — it just never ran, because of one overly-strict check.
|
|
10
|
+
|
|
11
|
+
## What changed, precisely
|
|
12
|
+
- Every thread has a saved "resume entry" that remembers which live session is handling it.
|
|
13
|
+
- Before delivering a follow-up, the router looks that entry up. The lookup had a guard: *"only return the entry if a saved transcript file exists on disk."*
|
|
14
|
+
- For agent-to-agent threads, the saved id was a **placeholder** (the real transcript id was never written back — a separate, known gap). So the guard found no transcript and threw the **entire entry away** — including the perfectly-good name of the session that was *still running right then*.
|
|
15
|
+
- With a null entry, the router skipped the "deliver into the live session" path and the "resume with history" path, and **cold-spawned a fresh memoryless session** every single time.
|
|
16
|
+
- The fix has two halves. **(1)** When there's no transcript file, don't discard the entry if its **tmux session is still alive** — hand it back so the follow-up routes into the running session (live-inject, or a resume that carries the full conversation history) instead of cold-spawning. **(2)** The bookkeeping that records *which* session is handling a thread was writing down a **made-up name** instead of the real one (the spawn helper only handed back an internal id, never the actual session name), so step 1 could never find the session. Now the real session name is passed through and recorded — so the liveness check actually matches, and the resume bookkeeping can later attach the real transcript too.
|
|
17
|
+
|
|
18
|
+
## The safety rails (why this won't break anything)
|
|
19
|
+
1. **Only triggers on the exact broken case** — a non-topic-bound thread whose transcript is missing. The healthy path (real transcript exists) is untouched and never even runs the liveness check.
|
|
20
|
+
2. **Dead session → unchanged.** If the session is gone *and* there's no transcript, it still returns nothing → cold-spawn, exactly like before.
|
|
21
|
+
3. **Topic-bound and pinned threads** keep their existing exemptions — no change.
|
|
22
|
+
4. **The injection path already protects itself.** If the session dies in the split-second between the check and the delivery, the delivery fails gracefully and falls through to a fresh session *with the conversation history in its prompt* — which is still coherent, never a crash.
|
|
23
|
+
5. **Exact session match only** (`=name`) — no fuzzy/prefix matching that could deliver to the wrong session.
|
|
24
|
+
|
|
25
|
+
## Why it matters
|
|
26
|
+
This is the core of "an Instar agent is one coherent individual." Agent-to-agent collaboration — the foundation the whole feedback-process migration rides on — was silently incoherent: each reply came from an amnesiac. This makes a live thread behave like one continuous mind, which is the bar we set.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
**Rendered (verified) view:** _set below after creating the tunnel view._
|