@agfpd/iapeer 0.2.34 → 0.2.35
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/package.json +1 -1
- package/src/cli/cli.test.ts +23 -0
- package/src/cli/index.ts +22 -6
- package/src/daemon/index.ts +12 -1
- package/src/daemon/main.test.ts +63 -0
- package/src/daemon/main.ts +36 -0
- package/src/lifecycle/index.ts +9 -3
- package/src/transport/index.ts +25 -0
- package/src/transport/routesend-wake.test.ts +69 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -145,6 +145,29 @@ describe('stop / start (C1 durable flag, warm runtime)', () => {
|
|
|
145
145
|
expect(isStopped(cfg, 'claude-parked')).toBe(false)
|
|
146
146
|
expect(hasIdleReaped(cfg, 'claude-parked')).toBe(true)
|
|
147
147
|
})
|
|
148
|
+
test('start --all clears the flag of EVERY registered peer; unregistered garbage untouched (incident 11.06)', async () => {
|
|
149
|
+
await register('p1')
|
|
150
|
+
await register('p2')
|
|
151
|
+
const e = env()
|
|
152
|
+
const cfg = loadLifecycleConfig(e)
|
|
153
|
+
// a stopped fleet …
|
|
154
|
+
setStopped(cfg, 'claude-p1')
|
|
155
|
+
setStopped(cfg, 'claude-p2')
|
|
156
|
+
// … plus a stray stop-flag for an identity NO registry record claims
|
|
157
|
+
setStopped(cfg, 'claude-ghost')
|
|
158
|
+
expect(await runCli(['start', '--all'], e)).toBe(0)
|
|
159
|
+
expect(isStopped(cfg, 'claude-p1')).toBe(false)
|
|
160
|
+
expect(isStopped(cfg, 'claude-p2')).toBe(false)
|
|
161
|
+
expect(isStopped(cfg, 'claude-ghost')).toBe(true) // unregistered → never enumerated, never touched
|
|
162
|
+
})
|
|
163
|
+
test('start without a peer and without --all → usage, nothing executed', async () => {
|
|
164
|
+
await register('p3')
|
|
165
|
+
const e = env()
|
|
166
|
+
const cfg = loadLifecycleConfig(e)
|
|
167
|
+
setStopped(cfg, 'claude-p3')
|
|
168
|
+
expect(await runCli(['start'], e)).toBe(2)
|
|
169
|
+
expect(isStopped(cfg, 'claude-p3')).toBe(true) // flag untouched — usage only
|
|
170
|
+
})
|
|
148
171
|
})
|
|
149
172
|
|
|
150
173
|
describe('FLEET GUARD (H4) — foreign persistent-peer launchd plist is off-limits', () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -445,7 +445,7 @@ export async function sendMessage(
|
|
|
445
445
|
// unawaited in-process wake would die with it; the daemon's supervise-tick
|
|
446
446
|
// drain scan (≤60 s) picks the queue up — the EXISTING retry path for failed
|
|
447
447
|
// kicks, not a new mechanism.
|
|
448
|
-
const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps } = await import('../daemon/main.ts')
|
|
448
|
+
const { makeArmEphemeralOnDelivered, makeEphemeralRouteDeps, makeNoteLiveTopic } = await import('../daemon/main.ts')
|
|
449
449
|
const cfg = loadLifecycleConfig(env)
|
|
450
450
|
const t0 = Date.now()
|
|
451
451
|
const result = await routeSend(
|
|
@@ -457,7 +457,10 @@ export async function sendMessage(
|
|
|
457
457
|
topic: opts.topic,
|
|
458
458
|
attachments: opts.attachments,
|
|
459
459
|
},
|
|
460
|
-
|
|
460
|
+
// noteLiveTopic — CLI-path parity (same seam the daemon wires): a live delivery
|
|
461
|
+
// through the CLI fallback must update the target's .topic marker too, or a
|
|
462
|
+
// daemon-restart-window send re-opens the stale-marker false-fresh (defect 11.06).
|
|
463
|
+
{ wake: cliWake, ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}), noteLiveTopic: makeNoteLiveTopic(cfg, env) },
|
|
461
464
|
)
|
|
462
465
|
// delivery.log sink — CLI-path parity (boris's observability gap 10.06: enqueues
|
|
463
466
|
// routed through the CLI left to=<peer> at ZERO for the day while real wakes
|
|
@@ -552,8 +555,10 @@ const USAGE = `usage: iapeer <verb> [args]
|
|
|
552
555
|
create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
|
|
553
556
|
list [--json] registered peers + per-runtime liveness
|
|
554
557
|
verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index)
|
|
555
|
-
stop <peer> [runtime] | --all durable-
|
|
556
|
-
|
|
558
|
+
stop <peer> [runtime] | --all warm peer: kill the live session + set a durable stop-flag — the daemon will NOT wake it
|
|
559
|
+
until \`start\`; always-on (notifier/telegram): launchctl bootout. --all = every registered peer
|
|
560
|
+
start <peer> [runtime] | --all warm peer: clear the stop-flag — wakeable again; does NOT launch a session (the daemon brings
|
|
561
|
+
it up on the peer's first message); always-on: launchctl bootstrap. --all = every registered peer
|
|
557
562
|
remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
|
|
558
563
|
send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
|
|
559
564
|
<runtime> launch the cwd's peer (ALWAYS fresh)
|
|
@@ -948,8 +953,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
948
953
|
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
949
954
|
}
|
|
950
955
|
case 'start': {
|
|
951
|
-
|
|
952
|
-
|
|
956
|
+
// --all re-enables every REGISTERED peer (mirror of `stop --all` — the 11.06
|
|
957
|
+
// incident: bringing a stopped fleet back required a manual loop over ~25
|
|
958
|
+
// peers). Enumeration is the registry, so unregistered garbage (stray flag
|
|
959
|
+
// files for identities no record claims) is never touched. NOTE: for warm
|
|
960
|
+
// peers this clears the stop flag only — no session is launched; each peer
|
|
961
|
+
// becomes wakeable and the daemon brings its session up on the first message.
|
|
962
|
+
const peers = flags.all === true
|
|
963
|
+
? readPeersIndex({ env }).peers.map(p => p.personality)
|
|
964
|
+
: positionals[0]
|
|
965
|
+
? [positionals[0]]
|
|
966
|
+
: null
|
|
967
|
+
if (!peers) return usage(errOut)
|
|
968
|
+
const outcomes = peers.flatMap(p => startPeer(p, flags.all === true ? undefined : positionals[1], { env }))
|
|
953
969
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
954
970
|
return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
|
|
955
971
|
}
|
package/src/daemon/index.ts
CHANGED
|
@@ -188,6 +188,10 @@ export interface CallToolDeps {
|
|
|
188
188
|
onDelivered?: (caller: ResolvedCaller) => void
|
|
189
189
|
/** Ephemeral-target serial-queue delivery (M3) — see transport.EphemeralRouteDeps. */
|
|
190
190
|
ephemeral?: EphemeralRouteDeps
|
|
191
|
+
/** LIVE-delivered topic → the target's `.topic` marker (fresh-vs-resume seam) —
|
|
192
|
+
* see transport.RouteDeps.noteLiveTopic. Injected so the daemon never imports
|
|
193
|
+
* lifecycle; the production main wires makeNoteLiveTopic. */
|
|
194
|
+
noteLiveTopic?: (identity: string, topic: string) => void
|
|
191
195
|
}
|
|
192
196
|
|
|
193
197
|
export async function callTool(
|
|
@@ -205,7 +209,7 @@ export async function callTool(
|
|
|
205
209
|
attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
|
|
206
210
|
}
|
|
207
211
|
const t0 = Date.now()
|
|
208
|
-
const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral })
|
|
212
|
+
const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral, noteLiveTopic: deps.noteLiveTopic })
|
|
209
213
|
// Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
|
|
210
214
|
// to lifecycle.log) — metadata only, never the body. Both branches of the
|
|
211
215
|
// routeSend result are recorded, so a suspected loss is reconstructable from
|
|
@@ -367,6 +371,12 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
367
371
|
* lifecycle queue + drain (makeEphemeralRouteDeps).
|
|
368
372
|
*/
|
|
369
373
|
ephemeral?: EphemeralRouteDeps
|
|
374
|
+
/**
|
|
375
|
+
* LIVE-delivered topic → the target identity's `.topic` marker (fresh-vs-resume
|
|
376
|
+
* seam) — see transport.RouteDeps.noteLiveTopic. OFF by default; the production
|
|
377
|
+
* main wires the lifecycle marker write (makeNoteLiveTopic).
|
|
378
|
+
*/
|
|
379
|
+
noteLiveTopic?: (identity: string, topic: string) => void
|
|
370
380
|
/**
|
|
371
381
|
* Write the discovery file (router.json) at <root>/state/iapeer/router.json with
|
|
372
382
|
* the active addresses `{sock, tcp}` — atomically on listen, removed on close.
|
|
@@ -399,6 +409,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
399
409
|
deliveryLogDir: opts.deliveryLogDir,
|
|
400
410
|
onDelivered: opts.onDelivered,
|
|
401
411
|
ephemeral: opts.ephemeral,
|
|
412
|
+
noteLiveTopic: opts.noteLiveTopic,
|
|
402
413
|
})
|
|
403
414
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
404
415
|
res.on('close', () => {
|
package/src/daemon/main.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
installDaemonPlist,
|
|
16
16
|
makeArmEphemeralOnDelivered,
|
|
17
17
|
makeEphemeralRouteDeps,
|
|
18
|
+
makeNoteLiveTopic,
|
|
18
19
|
startConfiguredDaemon,
|
|
19
20
|
DEFAULT_DAEMON_PORT,
|
|
20
21
|
} from './main.ts'
|
|
@@ -23,6 +24,10 @@ import {
|
|
|
23
24
|
ephemeralQueueDepth,
|
|
24
25
|
hasEphemeralArmed,
|
|
25
26
|
peekEphemeralTask,
|
|
27
|
+
readTopic,
|
|
28
|
+
resolveWakeMode,
|
|
29
|
+
setIdleReaped,
|
|
30
|
+
writeTopic,
|
|
26
31
|
type LifecycleConfig,
|
|
27
32
|
} from '../lifecycle/index.ts'
|
|
28
33
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
@@ -304,3 +309,61 @@ describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
|
|
|
304
309
|
expect(r.ok).toBe(false) // 'codex' is not declared for this worker
|
|
305
310
|
})
|
|
306
311
|
})
|
|
312
|
+
|
|
313
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
314
|
+
// makeNoteLiveTopic — the live-delivered-topic composition (fresh-vs-resume seam):
|
|
315
|
+
// the .topic marker must track the topic the session last WORKED ON, not the one
|
|
316
|
+
// it woke with (defect 11.06: a day-stale wake-time marker false-freshed the
|
|
317
|
+
// stop→start resume of a long-lived executor).
|
|
318
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
describe('makeNoteLiveTopic (live-delivered topic → .topic marker)', () => {
|
|
321
|
+
function noteCfg(): LifecycleConfig {
|
|
322
|
+
const root = mkTmp()
|
|
323
|
+
return { stateDir: join(root, 'state'), eventLogDir: join(root, 'logs') } as LifecycleConfig
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
test('topic SHIFT → marker updated + ev=topic-note logged; same topic → no churn, no log spam', () => {
|
|
327
|
+
const cfg = noteCfg()
|
|
328
|
+
const note = makeNoteLiveTopic(cfg, {} as NodeJS.ProcessEnv)
|
|
329
|
+
writeTopic(cfg, 'claude-x', 'topic-a') // the wake established topic A
|
|
330
|
+
note('claude-x', 'topic-b') // a live delivery switches the work to B
|
|
331
|
+
expect(readTopic(cfg, 'claude-x')).toBe('topic-b')
|
|
332
|
+
const logPath = join(cfg.eventLogDir, 'lifecycle.log')
|
|
333
|
+
const log = readFileSync(logPath, 'utf8')
|
|
334
|
+
expect(log).toContain('ev=topic-note')
|
|
335
|
+
expect(log).toContain('identity=claude-x')
|
|
336
|
+
expect(log).toContain('topic=topic-b')
|
|
337
|
+
// the common case — every further message continues topic B → strictly nothing
|
|
338
|
+
note('claude-x', 'topic-b')
|
|
339
|
+
note('claude-x', 'topic-b')
|
|
340
|
+
expect(readFileSync(logPath, 'utf8').match(/ev=topic-note/g)?.length).toBe(1)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('acceptance scenario (boris 11.06): woke with topic A → worked live-delivered topic B → clean stop-park → wake with topic B = RESUME, not fresh', () => {
|
|
344
|
+
const cfg = noteCfg()
|
|
345
|
+
const id = 'claude-x'
|
|
346
|
+
// executor peer cwd: no telegram interface (not human-conversational), no wake_policy
|
|
347
|
+
const cwd = mkTmp()
|
|
348
|
+
mkdirSync(join(cwd, '.iapeer'), { recursive: true })
|
|
349
|
+
writeFileSync(join(cwd, '.iapeer', 'peer-profile.json'), JSON.stringify({ personality: 'x', runtime: 'claude' }))
|
|
350
|
+
writeTopic(cfg, id, 'topic-a') // session woke with topic A (the stale pre-fix marker)
|
|
351
|
+
makeNoteLiveTopic(cfg)(id, 'topic-b') // ...then took topic-B messages in the LIVE session all day
|
|
352
|
+
setIdleReaped(cfg, id) // deliberate stop = clean park (stopPeer's promise)
|
|
353
|
+
const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
|
|
354
|
+
// pre-fix this read the stale 'topic-a' → cause=idle-reaped-new-topic → FRESH (context lost)
|
|
355
|
+
expect(resolveWakeMode(cfg, id, cwd, undefined, hasTranscript, 'topic-b')).toEqual({
|
|
356
|
+
resume: true,
|
|
357
|
+
resumeRef: 'uuid-1',
|
|
358
|
+
cause: 'idle-reaped-resume',
|
|
359
|
+
})
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test('marker/log failure → no-op, never throws (a delivered message must stand)', () => {
|
|
363
|
+
const root = mkTmp()
|
|
364
|
+
const blocker = join(root, 'state-is-a-file')
|
|
365
|
+
writeFileSync(blocker, 'not a dir')
|
|
366
|
+
const cfg = { stateDir: join(blocker, 'state'), eventLogDir: join(blocker, 'logs') } as LifecycleConfig // mkdir must fail
|
|
367
|
+
expect(() => makeNoteLiveTopic(cfg)('claude-x', 'topic-b')).not.toThrow()
|
|
368
|
+
})
|
|
369
|
+
})
|
package/src/daemon/main.ts
CHANGED
|
@@ -35,12 +35,15 @@ import {
|
|
|
35
35
|
isEphemeralPeer,
|
|
36
36
|
loadLifecycleConfig,
|
|
37
37
|
processEagerRelaunches,
|
|
38
|
+
readTopic,
|
|
38
39
|
resolveWakeRuntime,
|
|
39
40
|
setEphemeralArmed,
|
|
40
41
|
superviseTick,
|
|
41
42
|
wakeOrSpawn,
|
|
43
|
+
writeTopic,
|
|
42
44
|
type LifecycleConfig,
|
|
43
45
|
} from '../lifecycle/index.ts'
|
|
46
|
+
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
44
47
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
45
48
|
import { err, ok } from '../core/errors.ts'
|
|
46
49
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
@@ -98,6 +101,35 @@ export function makeArmEphemeralOnDelivered(cfg: LifecycleConfig): (caller: Reso
|
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The fourth lifecycle⇆transport seam (after makeWakeFn / makeArmEphemeralOnDelivered /
|
|
106
|
+
* makeEphemeralRouteDeps): record a LIVE-delivered topic as the target identity's
|
|
107
|
+
* `.topic` marker, so the marker tracks the topic the session LAST WORKED ON — the
|
|
108
|
+
* semantics resolveWakeMode's executor discriminator actually wants — not merely the
|
|
109
|
+
* topic it woke with. Live defect 11.06 (boris): an executor woke topicless at 13:13,
|
|
110
|
+
* worked a delivered topic all day, was stop-parked clean, and the topic-matching
|
|
111
|
+
* resume-wake at 17:06 compared against the STALE wake-time marker → cause=
|
|
112
|
+
* idle-reaped-new-topic → FRESH; the day's context was lost despite the clean park.
|
|
113
|
+
* Same-topic deliveries are a no-op (no marker churn, no log spam) — the lifecycle.log
|
|
114
|
+
* line `ev=topic-note` therefore marks exactly the moments the session's working
|
|
115
|
+
* topic SHIFTED, which is what a fresh-vs-resume postmortem needs. Best-effort: a
|
|
116
|
+
* marker/log failure never fails the already-delivered message.
|
|
117
|
+
*/
|
|
118
|
+
export function makeNoteLiveTopic(
|
|
119
|
+
cfg: LifecycleConfig,
|
|
120
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
121
|
+
): (identity: string, topic: string) => void {
|
|
122
|
+
return (identity, topic) => {
|
|
123
|
+
try {
|
|
124
|
+
if (readTopic(cfg, identity) === topic) return
|
|
125
|
+
writeTopic(cfg, identity, topic)
|
|
126
|
+
appendLifecycleEvent(cfg.eventLogDir, { ev: 'topic-note', identity, topic }, { env })
|
|
127
|
+
} catch {
|
|
128
|
+
/* the marker is a discriminator hint — never fail a delivered message */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
101
133
|
/**
|
|
102
134
|
* wake_policy:"ephemeral" M3 — the serial-queue delivery composition (the third
|
|
103
135
|
* lifecycle⇆transport seam after makeWakeFn / makeArmEphemeralOnDelivered).
|
|
@@ -178,6 +210,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
178
210
|
// M3 serial queue (see makeEphemeralRouteDeps): ephemeral TARGET ⇒ enqueue +
|
|
179
211
|
// async drain. No-op for every other target.
|
|
180
212
|
ephemeral: makeEphemeralRouteDeps(cfg, env),
|
|
213
|
+
// Live-delivered topic → the target's .topic marker (see makeNoteLiveTopic):
|
|
214
|
+
// the wake resolver's executor discriminator then compares against the topic
|
|
215
|
+
// the session last WORKED ON, not the one it woke with (defect 11.06).
|
|
216
|
+
noteLiveTopic: makeNoteLiveTopic(cfg, env),
|
|
181
217
|
supervise: {
|
|
182
218
|
intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
|
|
183
219
|
// idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -223,8 +223,12 @@ export function clearStopped(cfg: LifecycleConfig, identity: string): void {
|
|
|
223
223
|
// Presence on a dead session = the daemon EAGERLY relaunches FRESH (does NOT
|
|
224
224
|
// wait for a message) and injects initial_prompt. Consumed on the relaunch.
|
|
225
225
|
// .deaths : crash-loop guard — a small JSON ring of recent death epoch-ms.
|
|
226
|
-
// .topic : the topic
|
|
227
|
-
// resume discriminator).
|
|
226
|
+
// .topic : the topic the current/last session last WORKED ON (executor
|
|
227
|
+
// fresh-vs-resume discriminator). Written at wake (the boot topic) AND on
|
|
228
|
+
// every live delivery carrying a different non-empty topic (the daemon/CLI
|
|
229
|
+
// compositions wire transport's noteLiveTopic seam to writeTopic — defect
|
|
230
|
+
// 11.06: a wake-only marker goes stale over a long session and false-freshes
|
|
231
|
+
// the stop→start resume the clean park promised).
|
|
228
232
|
// .ephemeral-armed : wake_policy:ephemeral M2 — set by the DAEMON when it routes
|
|
229
233
|
// an OUTBOUND send from an ephemeral worker (its single final reply, ADR-006:
|
|
230
234
|
// workers send no intermediate messages, so outbound ⇒ the task is answered).
|
|
@@ -381,7 +385,9 @@ export function readTopic(cfg: LifecycleConfig, identity: string): string {
|
|
|
381
385
|
}
|
|
382
386
|
}
|
|
383
387
|
|
|
384
|
-
/** Store the
|
|
388
|
+
/** Store the session's working topic (raw string, best-effort). Two writers: the
|
|
389
|
+
* wake (boot topic, wakeOrSpawn) and live deliveries via the noteLiveTopic seam
|
|
390
|
+
* (makeNoteLiveTopic) — so the marker follows the work, not just the boot. */
|
|
385
391
|
export function writeTopic(cfg: LifecycleConfig, identity: string, topic: string): void {
|
|
386
392
|
mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
|
|
387
393
|
try {
|
package/src/transport/index.ts
CHANGED
|
@@ -440,6 +440,29 @@ export interface RouteDeps {
|
|
|
440
440
|
wake?: WakeFn
|
|
441
441
|
/** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
|
|
442
442
|
ephemeral?: EphemeralRouteDeps
|
|
443
|
+
/** Fired after a successful LIVE delivery whose envelope carried a non-empty
|
|
444
|
+
* topic — the hit path AND the post-wake fast-path redelivery, NOT the boot
|
|
445
|
+
* first-message (the wake itself records its topic). The fresh-vs-resume seam:
|
|
446
|
+
* lifecycle keeps a per-identity `.topic` marker the wake resolver compares
|
|
447
|
+
* the incoming topic against; before this hook the marker was written ONLY at
|
|
448
|
+
* wake, so it held «the topic the session woke with», while the resolver's
|
|
449
|
+
* semantics want «the topic the session last worked on». A long-lived executor
|
|
450
|
+
* that took live-delivered messages of topic B all day then false-FRESHED on
|
|
451
|
+
* stop→start→wake with topic B (live incident 11.06, claude-iapeer: a day of
|
|
452
|
+
* context lost to cause=idle-reaped-new-topic). Injected so transport never
|
|
453
|
+
* imports lifecycle (layering — main.ts is where the two meet); call sites
|
|
454
|
+
* swallow hook errors (a hook must never fail a delivered message). */
|
|
455
|
+
noteLiveTopic?: (identity: string, topic: string) => void
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Best-effort noteLiveTopic invocation — only on a non-empty topic, never throws. */
|
|
459
|
+
function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined): void {
|
|
460
|
+
if (!topic) return
|
|
461
|
+
try {
|
|
462
|
+
deps.noteLiveTopic?.(identity, topic)
|
|
463
|
+
} catch {
|
|
464
|
+
/* a post-delivery hook must never fail the delivery */
|
|
465
|
+
}
|
|
443
466
|
}
|
|
444
467
|
|
|
445
468
|
function truncateTopic(raw: string | undefined): string | undefined {
|
|
@@ -534,6 +557,7 @@ export async function routeSend(
|
|
|
534
557
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
535
558
|
const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
|
|
536
559
|
if (!delivered.ok) return delivered
|
|
560
|
+
noteTopic(deps, target.value.address, topic)
|
|
537
561
|
return ok({
|
|
538
562
|
ok: true,
|
|
539
563
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
@@ -567,6 +591,7 @@ export async function routeSend(
|
|
|
567
591
|
if (woke.taskDelivered === false) {
|
|
568
592
|
const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
|
|
569
593
|
if (!delivered.ok) return delivered
|
|
594
|
+
noteTopic(deps, live.value.address, topic)
|
|
570
595
|
return ok({
|
|
571
596
|
ok: true,
|
|
572
597
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
@@ -137,3 +137,72 @@ describe('routeSend wake fast-path (concurrent-sender envelope loss)', () => {
|
|
|
137
137
|
expect(r.error.message).toMatch(/verify-before-act|not live/)
|
|
138
138
|
})
|
|
139
139
|
})
|
|
140
|
+
|
|
141
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
// noteLiveTopic seam — a LIVE delivery carrying a topic must surface it to the
|
|
143
|
+
// injected hook (lifecycle's .topic marker write), so the fresh-vs-resume
|
|
144
|
+
// discriminator compares against the topic the session last WORKED ON, not the
|
|
145
|
+
// one it woke with (defect 11.06: stale wake-time marker → false fresh).
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
describe('routeSend noteLiveTopic seam (live-delivered topic)', () => {
|
|
149
|
+
test('live hit with a topic → hook fired ONCE with the TARGET identity + topic', async () => {
|
|
150
|
+
startTargetSession()
|
|
151
|
+
const notes: Array<[string, string]> = []
|
|
152
|
+
const r = await routeSend(
|
|
153
|
+
caller,
|
|
154
|
+
{ personality: TARGET, message: 'm', topic: 'fix-resume' },
|
|
155
|
+
{ noteLiveTopic: (id, t) => notes.push([id, t]) },
|
|
156
|
+
)
|
|
157
|
+
expect(r.ok).toBe(true)
|
|
158
|
+
expect(notes).toEqual([[`telegram-${TARGET}`, 'fix-resume']])
|
|
159
|
+
killTargetSession()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('no topic → hook NOT fired; a throwing hook never fails the delivered message', async () => {
|
|
163
|
+
startTargetSession()
|
|
164
|
+
const notes: string[] = []
|
|
165
|
+
const r1 = await routeSend(caller, { personality: TARGET, message: 'm1' }, { noteLiveTopic: () => { notes.push('x') } })
|
|
166
|
+
expect(r1.ok).toBe(true)
|
|
167
|
+
expect(notes).toEqual([])
|
|
168
|
+
const r2 = await routeSend(
|
|
169
|
+
caller,
|
|
170
|
+
{ personality: TARGET, message: 'm2', topic: 't' },
|
|
171
|
+
{ noteLiveTopic: () => { throw new Error('boom') } },
|
|
172
|
+
)
|
|
173
|
+
expect(r2.ok).toBe(true)
|
|
174
|
+
killTargetSession()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('post-wake fast-path redelivery (taskDelivered:false) also notes the topic — it is a LIVE delivery', async () => {
|
|
178
|
+
const notes: Array<[string, string]> = []
|
|
179
|
+
const wake: WakeFn = async () => {
|
|
180
|
+
startTargetSession()
|
|
181
|
+
return { status: 'READY', woke: false, runtime: 'telegram', taskDelivered: false }
|
|
182
|
+
}
|
|
183
|
+
const r = await routeSend(
|
|
184
|
+
caller,
|
|
185
|
+
{ personality: TARGET, message: 'm', topic: 'tt' },
|
|
186
|
+
{ wake, noteLiveTopic: (id, t) => notes.push([id, t]) },
|
|
187
|
+
)
|
|
188
|
+
expect(r.ok).toBe(true)
|
|
189
|
+
expect(notes).toEqual([[`telegram-${TARGET}`, 'tt']])
|
|
190
|
+
killTargetSession()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('boot first-message delivery (woke:true) does NOT fire the hook — the wake records its own topic', async () => {
|
|
194
|
+
const notes: string[] = []
|
|
195
|
+
const wake: WakeFn = async () => {
|
|
196
|
+
startTargetSession()
|
|
197
|
+
return { status: 'READY', woke: true, runtime: 'telegram', taskDelivered: true }
|
|
198
|
+
}
|
|
199
|
+
const r = await routeSend(
|
|
200
|
+
caller,
|
|
201
|
+
{ personality: TARGET, message: 'm', topic: 'tb' },
|
|
202
|
+
{ wake, noteLiveTopic: () => { notes.push('x') } },
|
|
203
|
+
)
|
|
204
|
+
expect(r.ok).toBe(true)
|
|
205
|
+
expect(notes).toEqual([])
|
|
206
|
+
killTargetSession()
|
|
207
|
+
})
|
|
208
|
+
})
|