@agfpd/iapeer 0.2.51 → 0.2.53
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 -1
- package/src/cli/index.ts +42 -6
- package/src/daemon/daemon.test.ts +2 -0
- package/src/daemon/index.ts +24 -6
- package/src/daemon/main.test.ts +19 -0
- package/src/daemon/main.ts +98 -4
- package/src/launch/adapters/claude.ts +1 -0
- package/src/launch/adapters/codex.ts +1 -0
- package/src/launch/launch.test.ts +2 -0
- package/src/launch/types.ts +8 -0
- package/src/transport/compactDone.test.ts +60 -0
- package/src/transport/composerQueue.test.ts +174 -0
- package/src/transport/index.ts +600 -1
- package/src/transport/routesend-wake.test.ts +52 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { addRuntime, compactPeer, defaultRuntime, formatListTable, listPeers, ne
|
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
12
|
import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
14
|
-
import { ok } from '../core/errors.ts'
|
|
14
|
+
import { err, ok } from '../core/errors.ts'
|
|
15
15
|
|
|
16
16
|
let root: string
|
|
17
17
|
let laDir: string
|
|
@@ -463,6 +463,7 @@ describe('compact (resume sleeping dialogue)', () => {
|
|
|
463
463
|
setIdleReaped(cfg, 'codex-sleepy')
|
|
464
464
|
const wakeCalls: unknown[] = []
|
|
465
465
|
const controlCalls: unknown[] = []
|
|
466
|
+
const doneCalls: unknown[] = []
|
|
466
467
|
const o = await compactPeer('sleepy', 'codex', {
|
|
467
468
|
env: e,
|
|
468
469
|
wakeFn: async args => {
|
|
@@ -473,13 +474,34 @@ describe('compact (resume sleeping dialogue)', () => {
|
|
|
473
474
|
controlCalls.push({ personality, runtime, command })
|
|
474
475
|
return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
|
|
475
476
|
},
|
|
477
|
+
compactDoneFn: (target, cwd, baseline) => {
|
|
478
|
+
doneCalls.push({ target, cwd, runtime: baseline.runtime })
|
|
479
|
+
return ok({ ok: true, ms: 42, signal: 'transcript+ready' })
|
|
480
|
+
},
|
|
476
481
|
})
|
|
477
482
|
expect(o).toMatchObject({ action: 'compacted', runtime: 'codex', woke: true })
|
|
478
483
|
expect(wakeCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', task: '', resume: true }])
|
|
479
484
|
expect(controlCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', command: { name: 'compact' } }])
|
|
485
|
+
expect(doneCalls).toHaveLength(1)
|
|
486
|
+
expect(doneCalls[0]).toMatchObject({ cwd: '/tmp/sleepy', runtime: 'codex', target: { runtime: 'codex', personality: 'sleepy' } })
|
|
480
487
|
expect(hasIdleReaped(cfg, 'codex-sleepy')).toBe(false)
|
|
481
488
|
})
|
|
482
489
|
|
|
490
|
+
test('compact done gate failure surfaces as failed (no false success)', async () => {
|
|
491
|
+
await register('slow', 'codex')
|
|
492
|
+
const e = env()
|
|
493
|
+
const cfg = loadLifecycleConfig(e)
|
|
494
|
+
setIdleReaped(cfg, 'codex-slow')
|
|
495
|
+
const o = await compactPeer('slow', 'codex', {
|
|
496
|
+
env: e,
|
|
497
|
+
wakeFn: async args => ({ status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }),
|
|
498
|
+
controlFn: (personality, runtime, command) => ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' }),
|
|
499
|
+
compactDoneFn: () => err('compact did not complete within test'),
|
|
500
|
+
})
|
|
501
|
+
expect(o.action).toBe('failed')
|
|
502
|
+
expect(o.reason).toContain('did not complete')
|
|
503
|
+
})
|
|
504
|
+
|
|
483
505
|
test('crashed / first-wake asleep peer (no clean idle marker) → nothing-to-compact, no fresh wake', async () => {
|
|
484
506
|
await register('freshy', 'codex')
|
|
485
507
|
let woke = false
|
package/src/cli/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } f
|
|
|
24
24
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
25
25
|
import { ensureGlobalIapScaffold } from '../storage/index.ts'
|
|
26
26
|
import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
|
|
27
|
-
import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
|
|
27
|
+
import { compactDoneBaseline, isPeerLive, routeControl, routeSend, waitForCompactDone, type WakeFn } from '../transport/index.ts'
|
|
28
28
|
import {
|
|
29
29
|
attachPeer,
|
|
30
30
|
clearIdleReaped,
|
|
@@ -515,9 +515,11 @@ export interface CompactPeerOutcome {
|
|
|
515
515
|
|
|
516
516
|
/**
|
|
517
517
|
* compact <peer> [runtime] — compact the DIALOGUE, not merely the currently-live
|
|
518
|
-
* session. A live target receives the in-session `/compact` control
|
|
519
|
-
*
|
|
520
|
-
*
|
|
518
|
+
* session. A live target receives the in-session `/compact` control; a cleanly
|
|
519
|
+
* idle-reaped target is first resumed into the same dialogue, then controlled.
|
|
520
|
+
* SUCCESS is gated on actual compaction completion (structured transcript marker
|
|
521
|
+
* after the command + input surface ready), not on keystrokes being accepted. A
|
|
522
|
+
* crashed / never-run / non-resumable target returns an honest "nothing to compact"
|
|
521
523
|
* instead of starting a fresh empty session and compacting that.
|
|
522
524
|
*/
|
|
523
525
|
export async function compactPeer(
|
|
@@ -526,6 +528,7 @@ export async function compactPeer(
|
|
|
526
528
|
opts: CliEnvOptions & {
|
|
527
529
|
wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: true }) => Promise<WakeResult>
|
|
528
530
|
controlFn?: typeof routeControl
|
|
531
|
+
compactDoneFn?: typeof waitForCompactDone
|
|
529
532
|
} = {},
|
|
530
533
|
): Promise<CompactPeerOutcome> {
|
|
531
534
|
const env = opts.env ?? process.env
|
|
@@ -551,10 +554,39 @@ export async function compactPeer(
|
|
|
551
554
|
}
|
|
552
555
|
|
|
553
556
|
const control = opts.controlFn ?? routeControl
|
|
557
|
+
const compactDone = opts.compactDoneFn ?? waitForCompactDone
|
|
558
|
+
const waitDone = (rt: Runtime, baseline: ReturnType<typeof compactDoneBaseline>, woke: boolean): CompactPeerOutcome | null => {
|
|
559
|
+
const done = compactDone(
|
|
560
|
+
{
|
|
561
|
+
personality,
|
|
562
|
+
runtime: rt,
|
|
563
|
+
address: buildProcessAddress(rt, personality),
|
|
564
|
+
socketPath: buildSocketPath(rt, personality, cfg.sockDir),
|
|
565
|
+
},
|
|
566
|
+
peer.cwd,
|
|
567
|
+
baseline,
|
|
568
|
+
{ env },
|
|
569
|
+
)
|
|
570
|
+
if (!done.ok) return { personality, runtime: rt, action: 'failed', woke, reason: done.error.message }
|
|
571
|
+
return null
|
|
572
|
+
}
|
|
554
573
|
const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
|
|
555
574
|
if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
|
|
556
|
-
const
|
|
557
|
-
|
|
575
|
+
const rt = (runtime as Runtime | undefined)
|
|
576
|
+
?? (liveRuntimes.includes(peer.runtime) ? peer.runtime : liveRuntimes.length === 1 ? liveRuntimes[0] : undefined)
|
|
577
|
+
if (!rt) {
|
|
578
|
+
return {
|
|
579
|
+
personality,
|
|
580
|
+
runtime: peer.runtime,
|
|
581
|
+
action: 'failed',
|
|
582
|
+
reason: `${personality} is online in multiple runtimes (${liveRuntimes.join(', ')}) — specify runtime`,
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
const baseline = compactDoneBaseline(rt, peer.cwd, { env })
|
|
586
|
+
const r = control(personality, rt, { name: 'compact' })
|
|
587
|
+
if (!r.ok) return { personality, runtime: rt, action: 'failed', reason: r.error.message }
|
|
588
|
+
const waited = waitDone(r.value.controlled.runtime as Runtime, baseline, false)
|
|
589
|
+
if (waited) return waited
|
|
558
590
|
return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
|
|
559
591
|
}
|
|
560
592
|
|
|
@@ -589,8 +621,11 @@ export async function compactPeer(
|
|
|
589
621
|
return { personality, runtime: rt, action: 'failed', reason }
|
|
590
622
|
}
|
|
591
623
|
clearIdleReaped(cfg, identity)
|
|
624
|
+
const baseline = compactDoneBaseline(rt, peer.cwd, { env })
|
|
592
625
|
const r = control(personality, rt, { name: 'compact' })
|
|
593
626
|
if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
|
|
627
|
+
const waited = waitDone(rt, baseline, true)
|
|
628
|
+
if (waited) return waited
|
|
594
629
|
return { personality, runtime: rt, action: 'compacted', woke: true }
|
|
595
630
|
}
|
|
596
631
|
|
|
@@ -803,6 +838,7 @@ export async function sendMessage(
|
|
|
803
838
|
via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
|
|
804
839
|
woke: result.ok ? String(result.value.woke) : undefined,
|
|
805
840
|
queued: result.ok && result.value.queued ? 'true' : undefined,
|
|
841
|
+
qkind: result.ok ? result.value.queuedBy : undefined,
|
|
806
842
|
qd: result.ok ? result.value.queueDepth : undefined,
|
|
807
843
|
ms: Date.now() - t0,
|
|
808
844
|
len: opts.message.length,
|
|
@@ -197,6 +197,7 @@ describe('ephemeral serial-queue seam (M3)', () => {
|
|
|
197
197
|
delivered_to: { personality: peer.personality, runtime: 'claude' },
|
|
198
198
|
woke: false,
|
|
199
199
|
queued: true,
|
|
200
|
+
queuedBy: 'ephemeral',
|
|
200
201
|
queueDepth: 2,
|
|
201
202
|
ts: 'now',
|
|
202
203
|
},
|
|
@@ -209,6 +210,7 @@ describe('ephemeral serial-queue seam (M3)', () => {
|
|
|
209
210
|
expect(delivered).toEqual(['checked:/tmp/offlinepeer', 'deliver:offlinepeer:job-1'])
|
|
210
211
|
const line = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim()
|
|
211
212
|
expect(line).toContain('queued=true')
|
|
213
|
+
expect(line).toContain('qkind=ephemeral')
|
|
212
214
|
expect(line).toContain('qd=2')
|
|
213
215
|
expect(line).toContain('ok=true')
|
|
214
216
|
})
|
package/src/daemon/index.ts
CHANGED
|
@@ -43,7 +43,7 @@ import { IapError } from '../core/errors.ts'
|
|
|
43
43
|
import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
44
44
|
import { publicPeerSummary, readPeersIndex, type PeersIndex } from '../registry/index.ts'
|
|
45
45
|
import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
|
|
46
|
-
import { routeSend, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
|
|
46
|
+
import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
|
|
47
47
|
import { appendDeliveryEvent } from './deliverylog.ts'
|
|
48
48
|
|
|
49
49
|
export const CALLER_HEADER = 'x-iapeer-identity'
|
|
@@ -192,6 +192,8 @@ export interface CallToolDeps {
|
|
|
192
192
|
* see transport.RouteDeps.noteLiveTopic. Injected so the daemon never imports
|
|
193
193
|
* lifecycle; the production main wires makeNoteLiveTopic. */
|
|
194
194
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
195
|
+
/** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
|
|
196
|
+
composerQueue?: ComposerQueueRouteDeps
|
|
195
197
|
}
|
|
196
198
|
|
|
197
199
|
export async function callTool(
|
|
@@ -209,7 +211,12 @@ export async function callTool(
|
|
|
209
211
|
attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
|
|
210
212
|
}
|
|
211
213
|
const t0 = Date.now()
|
|
212
|
-
const sent = await routeSend(caller, input, {
|
|
214
|
+
const sent = await routeSend(caller, input, {
|
|
215
|
+
wake: deps.wake,
|
|
216
|
+
ephemeral: deps.ephemeral,
|
|
217
|
+
noteLiveTopic: deps.noteLiveTopic,
|
|
218
|
+
composerQueue: deps.composerQueue,
|
|
219
|
+
})
|
|
213
220
|
// Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
|
|
214
221
|
// to lifecycle.log) — metadata only, never the body. Both branches of the
|
|
215
222
|
// routeSend result are recorded, so a suspected loss is reconstructable from
|
|
@@ -226,6 +233,7 @@ export async function callTool(
|
|
|
226
233
|
// M3 queue observability (boris acceptance (a)): a queued accept is marked
|
|
227
234
|
// and carries the queue depth at accept time — a stuck queue is visible.
|
|
228
235
|
queued: sent.ok && sent.value.queued ? 'true' : undefined,
|
|
236
|
+
qkind: sent.ok ? sent.value.queuedBy : undefined,
|
|
229
237
|
qd: sent.ok ? sent.value.queueDepth : undefined,
|
|
230
238
|
ms: Date.now() - t0,
|
|
231
239
|
len: input.message.length,
|
|
@@ -237,7 +245,7 @@ export async function callTool(
|
|
|
237
245
|
// reply is NOT out, and arming then could quiet-reap a worker mid-retry (the
|
|
238
246
|
// ordinary idle bound covers that case instead). Fail-safe: hook errors are
|
|
239
247
|
// swallowed, the delivery result stands.
|
|
240
|
-
if (sent.ok) {
|
|
248
|
+
if (sent.ok && sent.value.queuedBy !== 'composer') {
|
|
241
249
|
try {
|
|
242
250
|
deps.onDelivered?.(caller)
|
|
243
251
|
} catch {
|
|
@@ -377,6 +385,13 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
377
385
|
* main wires the lifecycle marker write (makeNoteLiveTopic).
|
|
378
386
|
*/
|
|
379
387
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
388
|
+
/**
|
|
389
|
+
* Busy-human-composer queue — async acceptance for live local TUI delivery when
|
|
390
|
+
* an attached operator has non-dim text in the target composer. OFF by default
|
|
391
|
+
* for library/tests; production main wires an in-memory queue that fail-notifies
|
|
392
|
+
* senders on shutdown/restart.
|
|
393
|
+
*/
|
|
394
|
+
composerQueue?: ComposerQueueRouteDeps
|
|
380
395
|
/**
|
|
381
396
|
* Write the discovery file (router.json) at <root>/state/iapeer/router.json with
|
|
382
397
|
* the active addresses `{sock, tcp}` — atomically on listen, removed on close.
|
|
@@ -410,6 +425,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
410
425
|
onDelivered: opts.onDelivered,
|
|
411
426
|
ephemeral: opts.ephemeral,
|
|
412
427
|
noteLiveTopic: opts.noteLiveTopic,
|
|
428
|
+
composerQueue: opts.composerQueue,
|
|
413
429
|
})
|
|
414
430
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
|
|
415
431
|
res.on('close', () => {
|
|
@@ -507,8 +523,9 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
507
523
|
host: tcpEnabled ? host : undefined,
|
|
508
524
|
port,
|
|
509
525
|
url,
|
|
510
|
-
close: () =>
|
|
511
|
-
|
|
526
|
+
close: async () => {
|
|
527
|
+
await opts.composerQueue?.failAll?.('daemon shutting down/restarting before queued delivery completed')
|
|
528
|
+
await new Promise<void>(resolve => {
|
|
512
529
|
if (supervisor) clearInterval(supervisor)
|
|
513
530
|
for (const s of servers) s.closeAllConnections?.() // drop lingering keep-alive/SSE conns
|
|
514
531
|
let pending = servers.length
|
|
@@ -525,6 +542,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
525
542
|
}
|
|
526
543
|
})
|
|
527
544
|
}
|
|
528
|
-
})
|
|
545
|
+
})
|
|
546
|
+
},
|
|
529
547
|
}
|
|
530
548
|
}
|
package/src/daemon/main.test.ts
CHANGED
|
@@ -232,6 +232,24 @@ describe('dual-listen + router.json discovery', () => {
|
|
|
232
232
|
})
|
|
233
233
|
})
|
|
234
234
|
|
|
235
|
+
describe('startDaemon composerQueue shutdown seam', () => {
|
|
236
|
+
test('close() fails pending busy-composer queue before tearing listeners down', async () => {
|
|
237
|
+
const reasons: string[] = []
|
|
238
|
+
const h = await startDaemon({
|
|
239
|
+
port: 0,
|
|
240
|
+
host: '127.0.0.1',
|
|
241
|
+
composerQueue: {
|
|
242
|
+
tryEnqueue: async () => null,
|
|
243
|
+
failAll: async reason => {
|
|
244
|
+
reasons.push(reason)
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
await h.close()
|
|
249
|
+
expect(reasons).toEqual(['daemon shutting down/restarting before queued delivery completed'])
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
235
253
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
254
|
// wake_policy:ephemeral M2 — makeArmEphemeralOnDelivered (the arm-on-outbound
|
|
237
255
|
// composition: ephemeral caller's ok send ⇒ .ephemeral-armed for its identity)
|
|
@@ -307,6 +325,7 @@ describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
|
|
|
307
325
|
const r2 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task two</iap>' })
|
|
308
326
|
|
|
309
327
|
expect(r1.ok && r1.value.queued).toBe(true)
|
|
328
|
+
expect(r1.ok && r1.value.queuedBy).toBe('ephemeral')
|
|
310
329
|
expect(r1.ok && r1.value.queueDepth).toBe(1)
|
|
311
330
|
expect(r1.ok && r1.value.woke).toBe(false)
|
|
312
331
|
expect(r2.ok && r2.value.queueDepth).toBe(2)
|
package/src/daemon/main.ts
CHANGED
|
@@ -46,9 +46,21 @@ import {
|
|
|
46
46
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
47
47
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
48
48
|
import { err, ok } from '../core/errors.ts'
|
|
49
|
+
import { resolveCallerIdentity } from '../identity/index.ts'
|
|
49
50
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
50
|
-
import
|
|
51
|
+
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
52
|
+
import {
|
|
53
|
+
createComposerDeliveryQueue,
|
|
54
|
+
routeSend,
|
|
55
|
+
type ComposerQueueRouteDeps,
|
|
56
|
+
type ComposerQueuedDelivery,
|
|
57
|
+
type EphemeralRouteDeps,
|
|
58
|
+
type WakeFn,
|
|
59
|
+
type WakeOutcome,
|
|
60
|
+
type WakeRequest,
|
|
61
|
+
} from '../transport/index.ts'
|
|
51
62
|
import { iapeerBinPath } from '../install/index.ts'
|
|
63
|
+
import { appendDeliveryEvent } from './deliverylog.ts'
|
|
52
64
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
53
65
|
|
|
54
66
|
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
@@ -168,6 +180,7 @@ export function makeEphemeralRouteDeps(
|
|
|
168
180
|
delivered_to: { personality: peer.personality, runtime: rt.value },
|
|
169
181
|
woke: false,
|
|
170
182
|
queued: true,
|
|
183
|
+
queuedBy: 'ephemeral',
|
|
171
184
|
queueDepth: depth,
|
|
172
185
|
ts: new Date().toISOString(),
|
|
173
186
|
})
|
|
@@ -175,6 +188,80 @@ export function makeEphemeralRouteDeps(
|
|
|
175
188
|
}
|
|
176
189
|
}
|
|
177
190
|
|
|
191
|
+
function queuedFailureMessage(job: ComposerQueuedDelivery, reason: string): string {
|
|
192
|
+
const topic = job.topic ? ` topic="${job.topic}"` : ''
|
|
193
|
+
return (
|
|
194
|
+
`delivery failed: queued message to ${job.target.personality} (${job.target.runtime})${topic} was not delivered.\n` +
|
|
195
|
+
`reason: ${reason}\n` +
|
|
196
|
+
'The original send was accepted as queued because a human operator was typing in the target composer; retry if still needed.'
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Busy-human-composer queue composition. This is deliberately daemon-owned and
|
|
202
|
+
* in-memory: it exists only to bridge a short operator-editing window. Because a
|
|
203
|
+
* queued ack is not a delivery fact, the queue has two extra responsibilities:
|
|
204
|
+
* 1) after actual drain success, run the same post-delivery hooks as the
|
|
205
|
+
* synchronous path (topic marker + ephemeral caller arm);
|
|
206
|
+
* 2) before daemon shutdown/restart, fail every pending queued envelope back
|
|
207
|
+
* to its sender, so `queued` never becomes silent loss.
|
|
208
|
+
*/
|
|
209
|
+
export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): ComposerQueueRouteDeps {
|
|
210
|
+
const noteLiveTopic = makeNoteLiveTopic(cfg, env)
|
|
211
|
+
const onDelivered = makeArmEphemeralOnDelivered(cfg)
|
|
212
|
+
const wake = makeWakeFn(cfg, env)
|
|
213
|
+
const ephemeral = makeEphemeralRouteDeps(cfg, env)
|
|
214
|
+
|
|
215
|
+
return createComposerDeliveryQueue({
|
|
216
|
+
env,
|
|
217
|
+
noteLiveTopic,
|
|
218
|
+
onDelivered,
|
|
219
|
+
notifyFailed: async (job, reason) => {
|
|
220
|
+
const index = readPeersIndex({ env })
|
|
221
|
+
const self = findPeer(index, 'iapeer')
|
|
222
|
+
if (!self) {
|
|
223
|
+
appendDeliveryEvent(cfg.eventLogDir, {
|
|
224
|
+
ev: 'composer-queue-failed-notify',
|
|
225
|
+
caller: 'iapeer',
|
|
226
|
+
to: job.caller.personality,
|
|
227
|
+
rt: job.caller.runtime,
|
|
228
|
+
ok: 'false',
|
|
229
|
+
err: 'foundation peer "iapeer" is not registered; cannot notify queued sender',
|
|
230
|
+
})
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
|
|
234
|
+
const result = await routeSend(
|
|
235
|
+
sender,
|
|
236
|
+
{
|
|
237
|
+
personality: job.caller.personality,
|
|
238
|
+
runtime: job.caller.runtime,
|
|
239
|
+
message: queuedFailureMessage(job, reason),
|
|
240
|
+
topic: job.topic ?? 'delivery-failed',
|
|
241
|
+
},
|
|
242
|
+
// Do not pass composerQueue here: this path is the queue's failure outlet
|
|
243
|
+
// (including SIGTERM/update). Recursively queueing a failure notification
|
|
244
|
+
// could turn shutdown re-fail into another queued item.
|
|
245
|
+
{ wake, ephemeral, noteLiveTopic },
|
|
246
|
+
)
|
|
247
|
+
appendDeliveryEvent(cfg.eventLogDir, {
|
|
248
|
+
ev: 'composer-queue-failed-notify',
|
|
249
|
+
caller: sender.address,
|
|
250
|
+
to: job.caller.personality,
|
|
251
|
+
rt: job.caller.runtime,
|
|
252
|
+
ok: String(result.ok),
|
|
253
|
+
via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
|
|
254
|
+
woke: result.ok ? String(result.value.woke) : undefined,
|
|
255
|
+
queued: result.ok && result.value.queued ? 'true' : undefined,
|
|
256
|
+
qkind: result.ok ? result.value.queuedBy : undefined,
|
|
257
|
+
qd: result.ok ? result.value.queueDepth : undefined,
|
|
258
|
+
topic: job.topic ?? 'delivery-failed',
|
|
259
|
+
err: result.ok ? undefined : result.error.message,
|
|
260
|
+
})
|
|
261
|
+
},
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
178
265
|
export interface ConfiguredDaemonOptions {
|
|
179
266
|
/** TCP loopback port (default DEFAULT_DAEMON_PORT). */
|
|
180
267
|
port?: number
|
|
@@ -202,18 +289,25 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
202
289
|
const env = opts.env ?? process.env
|
|
203
290
|
const cfg = loadLifecycleConfig(env)
|
|
204
291
|
const bearerToken = opts.bearerToken ?? (env.IAPEER_BEARER_TOKEN?.trim() || undefined)
|
|
292
|
+
const noteLiveTopic = makeNoteLiveTopic(cfg, env)
|
|
293
|
+
const armEphemeralOnDelivered = makeArmEphemeralOnDelivered(cfg)
|
|
294
|
+
const ephemeral = makeEphemeralRouteDeps(cfg, env)
|
|
295
|
+
const composerQueue = makeComposerQueueRouteDeps(cfg, env)
|
|
205
296
|
return startDaemon({
|
|
206
297
|
wake: makeWakeFn(cfg, env),
|
|
207
298
|
// M2 arm-on-outbound (see makeArmEphemeralOnDelivered): ephemeral caller's ok
|
|
208
299
|
// send ⇒ armed for the supervise quiet-reap. No-op for every other caller.
|
|
209
|
-
onDelivered:
|
|
300
|
+
onDelivered: armEphemeralOnDelivered,
|
|
210
301
|
// M3 serial queue (see makeEphemeralRouteDeps): ephemeral TARGET ⇒ enqueue +
|
|
211
302
|
// async drain. No-op for every other target.
|
|
212
|
-
ephemeral
|
|
303
|
+
ephemeral,
|
|
213
304
|
// Live-delivered topic → the target's .topic marker (see makeNoteLiveTopic):
|
|
214
305
|
// the wake resolver's executor discriminator then compares against the topic
|
|
215
306
|
// the session last WORKED ON, not the one it woke with (defect 11.06).
|
|
216
|
-
noteLiveTopic
|
|
307
|
+
noteLiveTopic,
|
|
308
|
+
// Busy-human-composer queue: fast queued ack, async drain, fail all pending
|
|
309
|
+
// on daemon close/update so a queued envelope is never silently lost.
|
|
310
|
+
composerQueue,
|
|
217
311
|
supervise: {
|
|
218
312
|
intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
|
|
219
313
|
// idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
|
|
@@ -304,10 +304,12 @@ describe('deliveryMarkers (adapter-owned, was transport PROMPT_GLYPHS)', () => {
|
|
|
304
304
|
test('claude: ❯ glyph + paste patterns', () => {
|
|
305
305
|
expect(claudeAdapter.deliveryMarkers.promptGlyphs).toEqual(['❯'])
|
|
306
306
|
expect(claudeAdapter.deliveryMarkers.pastePatterns?.some(re => re.test('[Pasted text +5 lines]'))).toBe(true)
|
|
307
|
+
expect(claudeAdapter.deliveryMarkers.ghostTextSgr).toEqual(['2', '38;5;246'])
|
|
307
308
|
})
|
|
308
309
|
test('codex: › glyph (not ❯) — per-runtime, no false cross-match', () => {
|
|
309
310
|
expect(codexAdapter.deliveryMarkers.promptGlyphs).toEqual(['›'])
|
|
310
311
|
expect(codexAdapter.deliveryMarkers.promptGlyphs).not.toContain('❯')
|
|
312
|
+
expect(codexAdapter.deliveryMarkers.ghostTextSgr).toEqual(['2', '38;5;246'])
|
|
311
313
|
})
|
|
312
314
|
test('routers have no submit surface → empty glyphs', () => {
|
|
313
315
|
expect(telegramAdapter.deliveryMarkers.promptGlyphs).toEqual([])
|
package/src/launch/types.ts
CHANGED
|
@@ -180,6 +180,14 @@ export interface DeliveryMarkers {
|
|
|
180
180
|
* (claude '[Pasted text' / '[Image #'). Optional — when absent the tail-marker is
|
|
181
181
|
* the sole landed-signal. */
|
|
182
182
|
pastePatterns?: RegExp[]
|
|
183
|
+
/** SGR parameter sequences that the runtime uses for composer ghost/placeholder
|
|
184
|
+
* text (non-human input). The busy-composer delivery queue captures the pane
|
|
185
|
+
* with ANSI escapes and treats visible text after the prompt glyph as HUMAN only
|
|
186
|
+
* when at least one non-whitespace character is NOT under these dim/grey SGR
|
|
187
|
+
* attributes. Runtime-owned for the same reason as promptGlyphs: color is TUI
|
|
188
|
+
* surface, not transport policy. Examples observed on live panes:
|
|
189
|
+
* `2` (faint/dim) and `38;5;246` (xterm 256-color grey). */
|
|
190
|
+
ghostTextSgr?: string[]
|
|
183
191
|
}
|
|
184
192
|
|
|
185
193
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, appendFileSync } from 'fs'
|
|
3
|
+
import { tmpdir } from 'os'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { compactDoneBaseline, compactTranscriptHasDone } from './index.ts'
|
|
6
|
+
|
|
7
|
+
function withHome(fn: (home: string, env: NodeJS.ProcessEnv) => void): void {
|
|
8
|
+
const home = mkdtempSync(join(tmpdir(), 'iapeer-compact-gate-home-'))
|
|
9
|
+
try {
|
|
10
|
+
fn(home, { ...process.env, HOME: home })
|
|
11
|
+
} finally {
|
|
12
|
+
rmSync(home, { recursive: true, force: true })
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function claudeSlug(cwd: string): string {
|
|
17
|
+
return realpathSync(cwd).replace(/[^a-zA-Z0-9]/g, '-')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('compact done transcript gate', () => {
|
|
21
|
+
test('codex: ignores old markers and detects a new context_compacted event', () => withHome((home, env) => {
|
|
22
|
+
const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-codex-cwd-'))
|
|
23
|
+
try {
|
|
24
|
+
const dir = join(home, '.codex', 'sessions', '2026', '06', '13')
|
|
25
|
+
mkdirSync(dir, { recursive: true })
|
|
26
|
+
const file = join(dir, 'rollout-test.jsonl')
|
|
27
|
+
writeFileSync(file,
|
|
28
|
+
JSON.stringify({ type: 'session_meta', payload: { cwd } }) + '\n' +
|
|
29
|
+
JSON.stringify({ type: 'event_msg', payload: { type: 'context_compacted' } }) + '\n',
|
|
30
|
+
)
|
|
31
|
+
const baseline = compactDoneBaseline('codex', cwd, { env })
|
|
32
|
+
expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
|
|
33
|
+
appendFileSync(file, JSON.stringify({ type: 'event_msg', payload: { type: 'context_compacted' } }) + '\n')
|
|
34
|
+
expect(compactTranscriptHasDone(baseline, { env })).toBe(true)
|
|
35
|
+
} finally {
|
|
36
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
37
|
+
}
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
test('claude: detects a new compact_boundary and ignores raw text mentions', () => withHome((home, env) => {
|
|
41
|
+
const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-claude-cwd-'))
|
|
42
|
+
try {
|
|
43
|
+
const dir = join(home, '.claude', 'projects', claudeSlug(cwd))
|
|
44
|
+
mkdirSync(dir, { recursive: true })
|
|
45
|
+
const file = join(dir, 'session.jsonl')
|
|
46
|
+
writeFileSync(file,
|
|
47
|
+
JSON.stringify({ type: 'user', message: { role: 'user', content: 'context_compacted is only text' } }) + '\n' +
|
|
48
|
+
JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted' }) + '\n',
|
|
49
|
+
)
|
|
50
|
+
const baseline = compactDoneBaseline('claude', cwd, { env })
|
|
51
|
+
expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
|
|
52
|
+
appendFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: 'Conversation compacted text only' } }) + '\n')
|
|
53
|
+
expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
|
|
54
|
+
appendFileSync(file, JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', compactMetadata: { durationMs: 12 } }) + '\n')
|
|
55
|
+
expect(compactTranscriptHasDone(baseline, { env })).toBe(true)
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
58
|
+
}
|
|
59
|
+
}))
|
|
60
|
+
})
|