@agfpd/iapeer 0.2.52 → 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/index.ts +1 -0
- 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/composerQueue.test.ts +174 -0
- package/src/transport/index.ts +375 -0
- package/src/transport/routesend-wake.test.ts +52 -0
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -838,6 +838,7 @@ export async function sendMessage(
|
|
|
838
838
|
via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
|
|
839
839
|
woke: result.ok ? String(result.value.woke) : undefined,
|
|
840
840
|
queued: result.ok && result.value.queued ? 'true' : undefined,
|
|
841
|
+
qkind: result.ok ? result.value.queuedBy : undefined,
|
|
841
842
|
qd: result.ok ? result.value.queueDepth : undefined,
|
|
842
843
|
ms: Date.now() - t0,
|
|
843
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,174 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { ok } from '../core/errors.ts'
|
|
3
|
+
import { getAdapter } from '../launch/index.ts'
|
|
4
|
+
import type { PeerRecord } from '../registry/index.ts'
|
|
5
|
+
import type { ResolvedCaller } from '../identity/index.ts'
|
|
6
|
+
import {
|
|
7
|
+
composerCaptureHasHumanInput,
|
|
8
|
+
createComposerDeliveryQueue,
|
|
9
|
+
type ComposerQueueTryEnqueueArgs,
|
|
10
|
+
type DeliveryTarget,
|
|
11
|
+
} from './index.ts'
|
|
12
|
+
|
|
13
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
14
|
+
|
|
15
|
+
describe('composerCaptureHasHumanInput (SGR color gate)', () => {
|
|
16
|
+
test('codex: default-fg text after › is human input; faint/grey ghost text is not', () => {
|
|
17
|
+
const markers = getAdapter('codex').deliveryMarkers
|
|
18
|
+
expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m hello', markers)).toBe(true)
|
|
19
|
+
expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m \x1b[2mghost suggestion\x1b[0m', markers)).toBe(false)
|
|
20
|
+
expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m \x1b[38;5;246mplaceholder\x1b[0m', markers)).toBe(false)
|
|
21
|
+
// A suggestion accepted into the composer is no longer grey/faint; it must be held.
|
|
22
|
+
expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m accepted suggestion', markers)).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('claude: same color rule is runtime-owned behind the ❯ prompt glyph', () => {
|
|
26
|
+
const markers = getAdapter('claude').deliveryMarkers
|
|
27
|
+
expect(composerCaptureHasHumanInput('\x1b[35m❯\x1b[0m human', markers)).toBe(true)
|
|
28
|
+
expect(composerCaptureHasHumanInput('\x1b[35m❯\x1b[0m \x1b[2mghost\x1b[0m', markers)).toBe(false)
|
|
29
|
+
expect(composerCaptureHasHumanInput('\x1b[35m❯\x1b[0m \x1b[38;5;246mplaceholder\x1b[0m', markers)).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const callerRecord = {
|
|
34
|
+
personality: 'sender',
|
|
35
|
+
runtime: 'codex',
|
|
36
|
+
runtimes: ['codex'],
|
|
37
|
+
description: '',
|
|
38
|
+
intelligence: 'artificial',
|
|
39
|
+
cwd: '/tmp/sender',
|
|
40
|
+
} as unknown as PeerRecord
|
|
41
|
+
const caller: ResolvedCaller = {
|
|
42
|
+
personality: 'sender',
|
|
43
|
+
runtime: 'codex',
|
|
44
|
+
address: 'codex-sender',
|
|
45
|
+
description: '',
|
|
46
|
+
intelligence: 'artificial',
|
|
47
|
+
cwd: '/tmp/sender',
|
|
48
|
+
record: callerRecord,
|
|
49
|
+
}
|
|
50
|
+
const peer = {
|
|
51
|
+
personality: 'target',
|
|
52
|
+
runtime: 'codex',
|
|
53
|
+
runtimes: ['codex'],
|
|
54
|
+
description: '',
|
|
55
|
+
intelligence: 'artificial',
|
|
56
|
+
cwd: '/tmp/target',
|
|
57
|
+
} as unknown as PeerRecord
|
|
58
|
+
const target: DeliveryTarget = {
|
|
59
|
+
personality: 'target',
|
|
60
|
+
runtime: 'codex',
|
|
61
|
+
address: 'codex-target',
|
|
62
|
+
socketPath: '/tmp/no-such-sock',
|
|
63
|
+
}
|
|
64
|
+
function args(envelope = '<iap>hello</iap>'): ComposerQueueTryEnqueueArgs {
|
|
65
|
+
return { caller, peer, target, envelope, topic: 'q-test' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('createComposerDeliveryQueue', () => {
|
|
69
|
+
test('queued ack is immediate; drain waits until composer clears, then delivers silently', async () => {
|
|
70
|
+
let held = true
|
|
71
|
+
const delivered: string[] = []
|
|
72
|
+
const failed: string[] = []
|
|
73
|
+
const q = createComposerDeliveryQueue({
|
|
74
|
+
pollMs: 5,
|
|
75
|
+
forceTimeoutMs: 1000,
|
|
76
|
+
shouldQueue: () => true,
|
|
77
|
+
hasHumanInput: () => held,
|
|
78
|
+
sessionToken: () => 'session-1:pane-1',
|
|
79
|
+
sessionAlive: () => true,
|
|
80
|
+
deliver: (_target, envelope) => {
|
|
81
|
+
delivered.push(envelope)
|
|
82
|
+
return ok(undefined)
|
|
83
|
+
},
|
|
84
|
+
notifyFailed: (_job, reason) => { failed.push(reason) },
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const accepted = await q.tryEnqueue(args('first'))
|
|
88
|
+
expect(accepted?.ok).toBe(true)
|
|
89
|
+
if (!accepted?.ok) return
|
|
90
|
+
expect(accepted.value).toMatchObject({ queued: true, queuedBy: 'composer', queueDepth: 1 })
|
|
91
|
+
await sleep(20)
|
|
92
|
+
expect(delivered).toEqual([])
|
|
93
|
+
held = false
|
|
94
|
+
await sleep(30)
|
|
95
|
+
expect(delivered).toEqual(['first'])
|
|
96
|
+
expect(failed).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('120s ceiling (env-tunable in tests) force-delivers instead of waiting forever', async () => {
|
|
100
|
+
const delivered: string[] = []
|
|
101
|
+
const failed: string[] = []
|
|
102
|
+
const q = createComposerDeliveryQueue({
|
|
103
|
+
pollMs: 5,
|
|
104
|
+
forceTimeoutMs: 20,
|
|
105
|
+
shouldQueue: () => true,
|
|
106
|
+
hasHumanInput: () => true,
|
|
107
|
+
sessionToken: () => 'session-1:pane-1',
|
|
108
|
+
sessionAlive: () => true,
|
|
109
|
+
deliver: (_target, envelope) => {
|
|
110
|
+
delivered.push(envelope)
|
|
111
|
+
return ok(undefined)
|
|
112
|
+
},
|
|
113
|
+
notifyFailed: (_job, reason) => { failed.push(reason) },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
await q.tryEnqueue(args('force-me'))
|
|
117
|
+
await sleep(70)
|
|
118
|
+
expect(delivered).toEqual(['force-me'])
|
|
119
|
+
expect(failed).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('target death/session replacement fails the queued sender instead of delivering to a corpse/new session', async () => {
|
|
123
|
+
let alive = true
|
|
124
|
+
const delivered: string[] = []
|
|
125
|
+
const failed: string[] = []
|
|
126
|
+
const q = createComposerDeliveryQueue({
|
|
127
|
+
pollMs: 5,
|
|
128
|
+
forceTimeoutMs: 1000,
|
|
129
|
+
shouldQueue: () => true,
|
|
130
|
+
hasHumanInput: () => true,
|
|
131
|
+
sessionToken: () => 'session-1:pane-1',
|
|
132
|
+
sessionAlive: () => alive,
|
|
133
|
+
deliver: (_target, envelope) => {
|
|
134
|
+
delivered.push(envelope)
|
|
135
|
+
return ok(undefined)
|
|
136
|
+
},
|
|
137
|
+
notifyFailed: (_job, reason) => { failed.push(reason) },
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
await q.tryEnqueue(args('must-fail'))
|
|
141
|
+
alive = false
|
|
142
|
+
await sleep(30)
|
|
143
|
+
expect(delivered).toEqual([])
|
|
144
|
+
expect(failed.join('\n')).toMatch(/vanished|replaced/)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('daemon close/restart failAll re-fails every queued-but-undelivered envelope', async () => {
|
|
148
|
+
const delivered: string[] = []
|
|
149
|
+
const failed: string[] = []
|
|
150
|
+
const q = createComposerDeliveryQueue({
|
|
151
|
+
pollMs: 50,
|
|
152
|
+
forceTimeoutMs: 1000,
|
|
153
|
+
shouldQueue: () => true,
|
|
154
|
+
hasHumanInput: () => true,
|
|
155
|
+
sessionToken: () => 'session-1:pane-1',
|
|
156
|
+
sessionAlive: () => true,
|
|
157
|
+
deliver: (_target, envelope) => {
|
|
158
|
+
delivered.push(envelope)
|
|
159
|
+
return ok(undefined)
|
|
160
|
+
},
|
|
161
|
+
notifyFailed: (_job, reason) => { failed.push(reason) },
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await q.tryEnqueue(args('queued-a'))
|
|
165
|
+
await q.tryEnqueue(args('queued-b'))
|
|
166
|
+
await q.failAll?.('daemon shutting down/restarting before queued delivery completed')
|
|
167
|
+
await sleep(10)
|
|
168
|
+
expect(delivered).toEqual([])
|
|
169
|
+
expect(failed).toEqual([
|
|
170
|
+
'daemon shutting down/restarting before queued delivery completed',
|
|
171
|
+
'daemon shutting down/restarting before queued delivery completed',
|
|
172
|
+
])
|
|
173
|
+
})
|
|
174
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -99,6 +99,168 @@ function sessionAlive(sock: string, address: string): boolean {
|
|
|
99
99
|
return listSessions(sock).includes(address)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// ─── Busy-composer detection (human input vs runtime ghost text) ─────────────
|
|
103
|
+
|
|
104
|
+
interface SgrState {
|
|
105
|
+
faint: boolean
|
|
106
|
+
fg256: number | null
|
|
107
|
+
fgBasic: number | null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface GhostSgrConfig {
|
|
111
|
+
faint: boolean
|
|
112
|
+
fg256: Set<number>
|
|
113
|
+
fgBasic: Set<number>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface PaneToken {
|
|
117
|
+
ch: string
|
|
118
|
+
ghost: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseSgrCodeList(raw: string): number[] | null {
|
|
122
|
+
if (raw.trim() === '') return [0]
|
|
123
|
+
const out = raw.split(';').map(part => (part === '' ? 0 : Number(part)))
|
|
124
|
+
return out.every(Number.isFinite) ? out : null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function ghostSgrConfig(markers: DeliveryMarkers): GhostSgrConfig {
|
|
128
|
+
const cfg: GhostSgrConfig = { faint: false, fg256: new Set(), fgBasic: new Set() }
|
|
129
|
+
for (const raw of markers.ghostTextSgr ?? []) {
|
|
130
|
+
const codes = parseSgrCodeList(raw)
|
|
131
|
+
if (!codes) continue
|
|
132
|
+
if (codes.length === 1 && codes[0] === 2) {
|
|
133
|
+
cfg.faint = true
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (codes.length === 1 && ((codes[0] >= 30 && codes[0] <= 37) || (codes[0] >= 90 && codes[0] <= 97))) {
|
|
137
|
+
cfg.fgBasic.add(codes[0])
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
if (codes.length === 3 && codes[0] === 38 && codes[1] === 5) cfg.fg256.add(codes[2])
|
|
141
|
+
}
|
|
142
|
+
return cfg
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function applySgr(state: SgrState, codes: number[]): void {
|
|
146
|
+
let i = 0
|
|
147
|
+
while (i < codes.length) {
|
|
148
|
+
const code = codes[i]
|
|
149
|
+
if (code === 0) {
|
|
150
|
+
state.faint = false
|
|
151
|
+
state.fg256 = null
|
|
152
|
+
state.fgBasic = null
|
|
153
|
+
} else if (code === 2) {
|
|
154
|
+
state.faint = true
|
|
155
|
+
} else if (code === 22) {
|
|
156
|
+
state.faint = false
|
|
157
|
+
} else if (code === 39) {
|
|
158
|
+
state.fg256 = null
|
|
159
|
+
state.fgBasic = null
|
|
160
|
+
} else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
|
161
|
+
state.fgBasic = code
|
|
162
|
+
state.fg256 = null
|
|
163
|
+
} else if (code === 38 && codes[i + 1] === 5 && Number.isFinite(codes[i + 2])) {
|
|
164
|
+
state.fg256 = codes[i + 2]
|
|
165
|
+
state.fgBasic = null
|
|
166
|
+
i += 2
|
|
167
|
+
} else if (code === 38 && codes[i + 1] === 2) {
|
|
168
|
+
// Truecolor foreground: not one of the calibrated grey-256 markers. Track
|
|
169
|
+
// it as "not ghost" by clearing the 256/basic foreground slots.
|
|
170
|
+
state.fg256 = null
|
|
171
|
+
state.fgBasic = null
|
|
172
|
+
i += 4
|
|
173
|
+
}
|
|
174
|
+
i += 1
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function stateIsGhost(state: SgrState, cfg: GhostSgrConfig): boolean {
|
|
179
|
+
if (cfg.faint && state.faint) return true
|
|
180
|
+
if (state.fg256 !== null && cfg.fg256.has(state.fg256)) return true
|
|
181
|
+
if (state.fgBasic !== null && cfg.fgBasic.has(state.fgBasic)) return true
|
|
182
|
+
return false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function parseAnsiLine(line: string, cfg: GhostSgrConfig): PaneToken[] {
|
|
186
|
+
const tokens: PaneToken[] = []
|
|
187
|
+
const state: SgrState = { faint: false, fg256: null, fgBasic: null }
|
|
188
|
+
let i = 0
|
|
189
|
+
while (i < line.length) {
|
|
190
|
+
if (line.charCodeAt(i) === 0x1b && line[i + 1] === '[') {
|
|
191
|
+
let end = i + 2
|
|
192
|
+
while (end < line.length && !/[A-Za-z]/.test(line[end])) end += 1
|
|
193
|
+
if (end < line.length) {
|
|
194
|
+
if (line[end] === 'm') {
|
|
195
|
+
const codes = parseSgrCodeList(line.slice(i + 2, end))
|
|
196
|
+
if (codes) applySgr(state, codes)
|
|
197
|
+
}
|
|
198
|
+
i = end + 1
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const ch = Array.from(line.slice(i))[0] ?? ''
|
|
203
|
+
if (!ch) break
|
|
204
|
+
tokens.push({ ch, ghost: stateIsGhost(state, cfg) })
|
|
205
|
+
i += ch.length
|
|
206
|
+
}
|
|
207
|
+
return tokens
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** True iff a captured TUI pane shows non-dim human text in the composer row.
|
|
211
|
+
* Grey/faint ghost suggestions and placeholders are intentionally NOT human
|
|
212
|
+
* input: holding IAP delivery behind them would be a false busy state. */
|
|
213
|
+
export function composerCaptureHasHumanInput(pane: string, markers: DeliveryMarkers): boolean {
|
|
214
|
+
if (markers.promptGlyphs.length === 0) return false
|
|
215
|
+
const cfg = ghostSgrConfig(markers)
|
|
216
|
+
let promptTokens: PaneToken[] | null = null
|
|
217
|
+
let promptIndex = -1
|
|
218
|
+
|
|
219
|
+
for (const line of pane.split(/\r?\n/)) {
|
|
220
|
+
const tokens = parseAnsiLine(line, cfg)
|
|
221
|
+
const firstVisible = tokens.findIndex(tok => tok.ch.trim() !== '')
|
|
222
|
+
if (firstVisible < 0) continue
|
|
223
|
+
if (!markers.promptGlyphs.includes(tokens[firstVisible].ch)) continue
|
|
224
|
+
promptTokens = tokens
|
|
225
|
+
promptIndex = firstVisible
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!promptTokens || promptIndex < 0) return false
|
|
229
|
+
for (const tok of promptTokens.slice(promptIndex + 1)) {
|
|
230
|
+
if (tok.ch.trim() === '') continue
|
|
231
|
+
if (!tok.ghost) return true
|
|
232
|
+
}
|
|
233
|
+
return false
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function hasAttachedTmuxClient(target: DeliveryTarget): boolean {
|
|
237
|
+
const r = tmux(target.socketPath, 'list-clients', '-F', '#{client_session}')
|
|
238
|
+
if (!r.ok) return false
|
|
239
|
+
return r.out.split(/\r?\n/).map(line => line.trim()).includes(target.address)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function targetComposerHasHumanInput(target: DeliveryTarget): boolean {
|
|
243
|
+
if (!isSupportedLocalRuntime(target.runtime)) return false
|
|
244
|
+
const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
|
|
245
|
+
if (!cap.ok) return false
|
|
246
|
+
return composerCaptureHasHumanInput(cap.out, getAdapter(target.runtime).deliveryMarkers)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function targetComposerBlocksDelivery(target: DeliveryTarget): boolean {
|
|
250
|
+
if (!isSupportedLocalRuntime(target.runtime)) return false
|
|
251
|
+
const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
|
|
252
|
+
// With a live resolved target, a capture failure is an uncertainty in the
|
|
253
|
+
// dangerous direction: prefer a bounded false-busy queue/force over a false-free
|
|
254
|
+
// paste into a human composer. If the session is actually gone, the queue's
|
|
255
|
+
// session-token/identity check fails loudly.
|
|
256
|
+
if (!cap.ok) return true
|
|
257
|
+
return composerCaptureHasHumanInput(cap.out, getAdapter(target.runtime).deliveryMarkers)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function shouldQueueForHumanComposer(target: DeliveryTarget): boolean {
|
|
261
|
+
return isSupportedLocalRuntime(target.runtime) && hasAttachedTmuxClient(target) && targetComposerBlocksDelivery(target)
|
|
262
|
+
}
|
|
263
|
+
|
|
102
264
|
/** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
|
|
103
265
|
export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir()): boolean {
|
|
104
266
|
const socketPath = buildSocketPath(runtime, personality, sockDir)
|
|
@@ -608,6 +770,11 @@ export interface RouteResult {
|
|
|
608
770
|
* substantive result arrives as the worker's own reply (FaaS semantics);
|
|
609
771
|
* `delivered_to` names the ACCEPTING queue identity, not a live session. */
|
|
610
772
|
queued?: boolean
|
|
773
|
+
/** Which async queue accepted the delivery. `ephemeral` is the durable worker
|
|
774
|
+
* task queue; `composer` is the daemon's in-memory busy-human-composer queue.
|
|
775
|
+
* Both surface as `queued:true`, but only `composer` means "not yet delivered
|
|
776
|
+
* and may still fail with an explicit sender notification". */
|
|
777
|
+
queuedBy?: 'ephemeral' | 'composer'
|
|
611
778
|
/** Queue depth right after the enqueue (observability; only with queued). */
|
|
612
779
|
queueDepth?: number
|
|
613
780
|
}
|
|
@@ -659,11 +826,44 @@ export interface EphemeralRouteDeps {
|
|
|
659
826
|
}) => Promise<Result<RouteResult>>
|
|
660
827
|
}
|
|
661
828
|
|
|
829
|
+
export interface ComposerQueuedDelivery {
|
|
830
|
+
id: number
|
|
831
|
+
caller: ResolvedCaller
|
|
832
|
+
peer: PeerRecord
|
|
833
|
+
target: DeliveryTarget
|
|
834
|
+
envelope: string
|
|
835
|
+
topic?: string
|
|
836
|
+
enqueuedAtMs: number
|
|
837
|
+
sessionToken: string
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export interface ComposerQueueTryEnqueueArgs {
|
|
841
|
+
caller: ResolvedCaller
|
|
842
|
+
peer: PeerRecord
|
|
843
|
+
target: DeliveryTarget
|
|
844
|
+
envelope: string
|
|
845
|
+
topic?: string
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
export interface ComposerQueueRouteDeps {
|
|
849
|
+
/** Return null when the live delivery should proceed synchronously; return a
|
|
850
|
+
* RouteResult when the envelope was accepted by the composer queue. */
|
|
851
|
+
tryEnqueue: (args: ComposerQueueTryEnqueueArgs) => Promise<Result<RouteResult> | null>
|
|
852
|
+
/** Fail every queued-but-not-yet-delivered envelope, used by daemon shutdown /
|
|
853
|
+
* restart so `queued` never degrades into silent loss. */
|
|
854
|
+
failAll?: (reason: string) => Promise<void>
|
|
855
|
+
}
|
|
856
|
+
|
|
662
857
|
export interface RouteDeps {
|
|
663
858
|
/** On a miss, wake the dead peer instead of returning offline (Ф2). */
|
|
664
859
|
wake?: WakeFn
|
|
665
860
|
/** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
|
|
666
861
|
ephemeral?: EphemeralRouteDeps
|
|
862
|
+
/** Busy-human-composer queue. When a LIVE local TUI target has an attached
|
|
863
|
+
* operator and non-dim text in its composer, routeSend returns a fast
|
|
864
|
+
* `{queued:true, queuedBy:"composer"}` ack and the daemon drains the message
|
|
865
|
+
* later instead of blindly paste+Enter-ing into the human's unfinished input. */
|
|
866
|
+
composerQueue?: ComposerQueueRouteDeps
|
|
667
867
|
/** Fired after a successful LIVE delivery whose envelope carried a non-empty
|
|
668
868
|
* topic — the hit path AND the post-wake fast-path redelivery, NOT the boot
|
|
669
869
|
* first-message (the wake itself records its topic). The fresh-vs-resume seam:
|
|
@@ -689,6 +889,177 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
|
|
|
689
889
|
}
|
|
690
890
|
}
|
|
691
891
|
|
|
892
|
+
export interface ComposerDeliveryQueueOptions {
|
|
893
|
+
env?: NodeJS.ProcessEnv
|
|
894
|
+
pollMs?: number
|
|
895
|
+
forceTimeoutMs?: number
|
|
896
|
+
/** Test seam / alternate detector. Production default is the full gate:
|
|
897
|
+
* local TUI runtime + attached tmux client + non-dim composer text. */
|
|
898
|
+
shouldQueue?: (args: ComposerQueueTryEnqueueArgs) => boolean
|
|
899
|
+
/** Test seam for the drain phase. Production default re-reads the pane color
|
|
900
|
+
* state (WITHOUT the attached-client prerequisite): once queued, abandoned
|
|
901
|
+
* human text waits until cleared or until the 120s force ceiling. */
|
|
902
|
+
hasHumanInput?: (target: DeliveryTarget) => boolean
|
|
903
|
+
/** Test seam; production default compares tmux session/pane identity captured
|
|
904
|
+
* at enqueue time, so `/new`/death/restart fails the old queued envelope rather
|
|
905
|
+
* than delivering it into a fresh session with the same name. */
|
|
906
|
+
sessionToken?: (target: DeliveryTarget) => string | null
|
|
907
|
+
sessionAlive?: (job: ComposerQueuedDelivery) => boolean
|
|
908
|
+
deliver?: (target: DeliveryTarget, envelope: string, cwd?: string) => Result<void>
|
|
909
|
+
notifyFailed: (job: ComposerQueuedDelivery, reason: string) => void | Promise<void>
|
|
910
|
+
noteLiveTopic?: (identity: string, topic: string) => void
|
|
911
|
+
onDelivered?: (caller: ResolvedCaller) => void
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function composerQueuePollMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
915
|
+
const raw = env?.IAPEER_COMPOSER_QUEUE_POLL_MS
|
|
916
|
+
const n = raw === undefined ? NaN : Number(raw)
|
|
917
|
+
return Number.isFinite(n) && n > 0 ? n : 500
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function composerQueueForceTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
921
|
+
const raw = env?.IAPEER_COMPOSER_QUEUE_TIMEOUT_MS
|
|
922
|
+
const n = raw === undefined ? NaN : Number(raw)
|
|
923
|
+
return Number.isFinite(n) && n >= 0 ? n : 120_000
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function delay(ms: number): Promise<void> {
|
|
927
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function targetSessionToken(target: DeliveryTarget): string | null {
|
|
931
|
+
const r = tmux(target.socketPath, 'display-message', '-p', '-t', target.address, '#{session_id}:#{pane_id}')
|
|
932
|
+
if (!r.ok) return null
|
|
933
|
+
return r.out.trim() || null
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function queuedTargetStillSameSession(job: ComposerQueuedDelivery): boolean {
|
|
937
|
+
return targetSessionToken(job.target) === job.sessionToken
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
let nextComposerQueueId = 1
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* In-memory async delivery queue for the narrow "operator is typing in the target
|
|
944
|
+
* TUI composer" case. It prevents the daemon's paste+Enter from concatenating an
|
|
945
|
+
* IAP envelope with human text already present in the shared composer stdin.
|
|
946
|
+
*
|
|
947
|
+
* Contract:
|
|
948
|
+
* - enqueue only on live local TUI + attached tmux client + non-dim composer text;
|
|
949
|
+
* - sender gets fast `{queued:true, queuedBy:"composer"}`;
|
|
950
|
+
* - FIFO drain delivers with the same deliverViaTmux Ф-B confirmation once the
|
|
951
|
+
* composer clears;
|
|
952
|
+
* - after 120s (env-tunable) force-deliver rather than hold forever;
|
|
953
|
+
* - target death/session replacement or daemon shutdown emits `failed` to sender
|
|
954
|
+
* via the injected notifier.
|
|
955
|
+
*/
|
|
956
|
+
export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions): ComposerQueueRouteDeps {
|
|
957
|
+
const env = opts.env ?? process.env
|
|
958
|
+
const pollMs = opts.pollMs ?? composerQueuePollMs(env)
|
|
959
|
+
const forceTimeoutMs = opts.forceTimeoutMs ?? composerQueueForceTimeoutMs(env)
|
|
960
|
+
const shouldQueue = opts.shouldQueue ?? ((args: ComposerQueueTryEnqueueArgs) => shouldQueueForHumanComposer(args.target))
|
|
961
|
+
const hasHumanInput = opts.hasHumanInput ?? targetComposerBlocksDelivery
|
|
962
|
+
const sessionToken = opts.sessionToken ?? targetSessionToken
|
|
963
|
+
const sessionAliveFn = opts.sessionAlive ?? queuedTargetStillSameSession
|
|
964
|
+
const deliver = opts.deliver ?? deliverViaTmux
|
|
965
|
+
const queues = new Map<string, ComposerQueuedDelivery[]>()
|
|
966
|
+
const draining = new Set<string>()
|
|
967
|
+
let closed = false
|
|
968
|
+
|
|
969
|
+
async function notifyFailed(job: ComposerQueuedDelivery, reason: string): Promise<void> {
|
|
970
|
+
try {
|
|
971
|
+
await opts.notifyFailed(job, reason)
|
|
972
|
+
} catch {
|
|
973
|
+
/* best-effort notification; delivery.log still has the queued accept */
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function kick(identity: string): void {
|
|
978
|
+
if (draining.has(identity)) return
|
|
979
|
+
draining.add(identity)
|
|
980
|
+
void drain(identity).finally(() => draining.delete(identity))
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function drain(identity: string): Promise<void> {
|
|
984
|
+
for (;;) {
|
|
985
|
+
const q = queues.get(identity)
|
|
986
|
+
const job = q?.[0]
|
|
987
|
+
if (!q || !job) {
|
|
988
|
+
queues.delete(identity)
|
|
989
|
+
return
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (!sessionAliveFn(job)) {
|
|
993
|
+
q.shift()
|
|
994
|
+
await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: target session vanished or was replaced before delivery`)
|
|
995
|
+
continue
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const ageMs = monotonicMs() - job.enqueuedAtMs
|
|
999
|
+
const force = ageMs >= forceTimeoutMs
|
|
1000
|
+
if (!force && hasHumanInput(job.target)) {
|
|
1001
|
+
await delay(pollMs)
|
|
1002
|
+
continue
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const delivered = deliver(job.target, job.envelope, job.peer.cwd)
|
|
1006
|
+
q.shift()
|
|
1007
|
+
if (!delivered.ok) {
|
|
1008
|
+
await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${delivered.error.message}`)
|
|
1009
|
+
} else {
|
|
1010
|
+
noteTopic({ noteLiveTopic: opts.noteLiveTopic }, job.target.address, job.topic)
|
|
1011
|
+
try {
|
|
1012
|
+
opts.onDelivered?.(job.caller)
|
|
1013
|
+
} catch {
|
|
1014
|
+
/* a post-delivery hook must never fail a delivery that already landed */
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return {
|
|
1021
|
+
tryEnqueue: async args => {
|
|
1022
|
+
if (closed) return err('composer delivery queue is closing; message NOT queued')
|
|
1023
|
+
if (!shouldQueue(args)) return null
|
|
1024
|
+
const token = sessionToken(args.target)
|
|
1025
|
+
if (!token) {
|
|
1026
|
+
return err(
|
|
1027
|
+
`composer queue could not identify the live tmux session for "${args.target.personality}" (${args.target.runtime}); message NOT queued`,
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
1030
|
+
const job: ComposerQueuedDelivery = {
|
|
1031
|
+
id: nextComposerQueueId++,
|
|
1032
|
+
caller: args.caller,
|
|
1033
|
+
peer: args.peer,
|
|
1034
|
+
target: args.target,
|
|
1035
|
+
envelope: args.envelope,
|
|
1036
|
+
topic: args.topic,
|
|
1037
|
+
enqueuedAtMs: monotonicMs(),
|
|
1038
|
+
sessionToken: token,
|
|
1039
|
+
}
|
|
1040
|
+
const q = queues.get(args.target.address) ?? []
|
|
1041
|
+
q.push(job)
|
|
1042
|
+
queues.set(args.target.address, q)
|
|
1043
|
+
kick(args.target.address)
|
|
1044
|
+
return ok({
|
|
1045
|
+
ok: true as const,
|
|
1046
|
+
delivered_to: { personality: args.target.personality, runtime: args.target.runtime },
|
|
1047
|
+
woke: false,
|
|
1048
|
+
queued: true,
|
|
1049
|
+
queuedBy: 'composer',
|
|
1050
|
+
queueDepth: q.length,
|
|
1051
|
+
ts: new Date().toISOString(),
|
|
1052
|
+
})
|
|
1053
|
+
},
|
|
1054
|
+
failAll: async reason => {
|
|
1055
|
+
closed = true
|
|
1056
|
+
const jobs = Array.from(queues.values()).flat()
|
|
1057
|
+
queues.clear()
|
|
1058
|
+
await Promise.all(jobs.map(job => notifyFailed(job, reason)))
|
|
1059
|
+
},
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
692
1063
|
function truncateTopic(raw: string | undefined): string | undefined {
|
|
693
1064
|
if (!raw) return undefined
|
|
694
1065
|
return raw.slice(0, MAX_TOPIC_LEN)
|
|
@@ -828,6 +1199,8 @@ export async function routeSend(
|
|
|
828
1199
|
// land on telegram without an override and without telegram being the default).
|
|
829
1200
|
const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
|
|
830
1201
|
if (!liveGuard.ok) return liveGuard
|
|
1202
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic })
|
|
1203
|
+
if (queued) return queued
|
|
831
1204
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
832
1205
|
const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
|
|
833
1206
|
if (!delivered.ok) return delivered
|
|
@@ -868,6 +1241,8 @@ export async function routeSend(
|
|
|
868
1241
|
// a false ok:true — the class the contract forbids. A delivery failure here is
|
|
869
1242
|
// surfaced loudly (false-FAIL over false-OK).
|
|
870
1243
|
if (woke.taskDelivered === false) {
|
|
1244
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic })
|
|
1245
|
+
if (queued) return queued
|
|
871
1246
|
const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
|
|
872
1247
|
if (!delivered.ok) return delivered
|
|
873
1248
|
noteTopic(deps, live.value.address, topic)
|
|
@@ -40,6 +40,9 @@ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status ===
|
|
|
40
40
|
const TARGET = 'fpw'
|
|
41
41
|
const SESSION = `telegram-${TARGET}`
|
|
42
42
|
const sockPath = () => join(sockDir, `tmux-iap-telegram-${TARGET}.sock`)
|
|
43
|
+
const TUI_TARGET = 'tq'
|
|
44
|
+
const TUI_SESSION = `codex-${TUI_TARGET}`
|
|
45
|
+
const tuiSockPath = () => join(sockDir, `tmux-iap-codex-${TUI_TARGET}.sock`)
|
|
43
46
|
|
|
44
47
|
const callerRecord = {
|
|
45
48
|
personality: 'boris',
|
|
@@ -79,6 +82,17 @@ function capturePane(): string {
|
|
|
79
82
|
return r.stdout ?? ''
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
function startTuiSession(): void {
|
|
86
|
+
const r = spawnSync('tmux', ['-S', tuiSockPath(), 'new-session', '-d', '-x', '200', '-y', '50', '-s', TUI_SESSION, 'cat'], {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
})
|
|
89
|
+
if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function killTuiSession(): void {
|
|
93
|
+
spawnSync('tmux', ['-S', tuiSockPath(), 'kill-server'], { encoding: 'utf8' })
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
beforeAll(() => {
|
|
83
97
|
root = mkdtempSync(join(tmpdir(), 'fpw-'))
|
|
84
98
|
sockDir = join(root, 'socks')
|
|
@@ -90,6 +104,7 @@ beforeAll(() => {
|
|
|
90
104
|
peers: [
|
|
91
105
|
{ personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/boris' },
|
|
92
106
|
{ personality: TARGET, runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'artificial', cwd: root },
|
|
107
|
+
{ personality: TUI_TARGET, runtime: 'codex', runtimes: ['codex'], description: '', intelligence: 'artificial', cwd: root },
|
|
93
108
|
],
|
|
94
109
|
}),
|
|
95
110
|
)
|
|
@@ -97,6 +112,7 @@ beforeAll(() => {
|
|
|
97
112
|
})
|
|
98
113
|
afterAll(() => {
|
|
99
114
|
killTargetSession()
|
|
115
|
+
killTuiSession()
|
|
100
116
|
if (prevRoot === undefined) delete process.env.IAPEER_ROOT
|
|
101
117
|
else process.env.IAPEER_ROOT = prevRoot
|
|
102
118
|
rmSync(root, { recursive: true, force: true })
|
|
@@ -218,6 +234,42 @@ describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)'
|
|
|
218
234
|
})
|
|
219
235
|
})
|
|
220
236
|
|
|
237
|
+
describe.if(tmuxAvailable)('routeSend busy-composer queue seam', () => {
|
|
238
|
+
test('live local TUI hit can return fast queued ack instead of delivering synchronously', async () => {
|
|
239
|
+
startTuiSession()
|
|
240
|
+
const seen: string[] = []
|
|
241
|
+
const r = await routeSend(
|
|
242
|
+
caller,
|
|
243
|
+
{ personality: TUI_TARGET, message: 'hold until operator submits', topic: 'composer-q' },
|
|
244
|
+
{
|
|
245
|
+
composerQueue: {
|
|
246
|
+
tryEnqueue: async ({ target, envelope, topic }) => {
|
|
247
|
+
seen.push(`${target.runtime}-${target.personality}:${topic}:${envelope.includes('hold until operator submits')}`)
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
value: {
|
|
251
|
+
ok: true as const,
|
|
252
|
+
delivered_to: { personality: target.personality, runtime: target.runtime },
|
|
253
|
+
woke: false,
|
|
254
|
+
queued: true,
|
|
255
|
+
queuedBy: 'composer',
|
|
256
|
+
queueDepth: 1,
|
|
257
|
+
ts: 'now',
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
expect(r.ok).toBe(true)
|
|
265
|
+
if (!r.ok) return
|
|
266
|
+
expect(r.value.queued).toBe(true)
|
|
267
|
+
expect(r.value.queuedBy).toBe('composer')
|
|
268
|
+
expect(seen).toEqual([`codex-${TUI_TARGET}:composer-q:true`])
|
|
269
|
+
killTuiSession()
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
221
273
|
// ─── Telegram sender policy (defect track 11.06) ─────────────────────────────
|
|
222
274
|
// A sender with NO declared telegram presence (interfaces.telegram.bot|user_id)
|
|
223
275
|
// must get a SYNCHRONOUS refusal when the delivery channel is telegram — the
|