@agfpd/iapeer 0.4.120 → 0.4.121
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/docs/20-opencode.md +8 -0
- package/package.json +1 -1
- package/src/cli/index.ts +121 -0
- package/src/daemon/main.ts +9 -2
- package/src/lifecycle/inflight.ts +26 -2
- package/src/transport/index.ts +61 -3
package/docs/20-opencode.md
CHANGED
|
@@ -175,6 +175,14 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
|
|
|
175
175
|
(lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
|
|
176
176
|
(delivery.log).
|
|
177
177
|
- A false «not processed» costs a visible duplicate, never a silent loss.
|
|
178
|
+
- Unknown is not failed. When a live receiver's acceptance record does not appear
|
|
179
|
+
within the confirm window (a busy claude records a queued message late), the
|
|
180
|
+
send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
|
|
181
|
+
sender must not resend. The ledger keeps the entry marked unconfirmed: once any
|
|
182
|
+
record carrying the envelope appears (including a claude `queue-operation`) it
|
|
183
|
+
is an ordinary pending entry; if none appears within 120 s the paste was
|
|
184
|
+
swallowed, and the ledger redelivers it. Each message is redelivered at most
|
|
185
|
+
once; a second miss becomes an explicit notice to the sender.
|
|
178
186
|
|
|
179
187
|
The real death cause is recorded too: the supervisor waits for the reap, so a
|
|
180
188
|
SIGKILL is logged as `dead_signal=SIGKILL`, never as a clean exit 0.
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -900,6 +900,117 @@ export async function compactPeer(
|
|
|
900
900
|
// ephemeral zombie records a retired spawn leaves behind.
|
|
901
901
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
902
902
|
|
|
903
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
904
|
+
// remove-runtime — the symmetric undo of add-runtime (one endpoint, not the peer)
|
|
905
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
export interface RemoveRuntimeOutcome {
|
|
908
|
+
personality: string
|
|
909
|
+
runtime: string
|
|
910
|
+
action: 'removed' | 'absent' | 'refused-default' | 'refused-last' | 'refused-live' | 'refused-infra'
|
|
911
|
+
reason?: string
|
|
912
|
+
/** What was undone, one line per surface. */
|
|
913
|
+
steps?: string[]
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Detach ONE runtime from a peer (OC14-2 — the pilot rollback of `add-runtime --all`). Undoes what
|
|
918
|
+
* add-runtime did for that endpoint, in this order: refuse unsafe cases (the default runtime, the last
|
|
919
|
+
* runtime, a live session unless --force, an infra channel — its plist teardown is `remove`'s job);
|
|
920
|
+
* unprovision the memory slot and every capability for THIS runtime (occasion=remove); runtime cleanup
|
|
921
|
+
* (opencode: its core private dir; codex: cwd trust); park the cwd discovery markers (`.claude`/`.codex`
|
|
922
|
+
* and `.iapeer/runtimes/<rt>`) as `<name>.removed-<ts>` so re-provision does not rediscover the
|
|
923
|
+
* runtime — data is kept, nothing is deleted from the peer folder; drop it from the local profile
|
|
924
|
+
* (source of truth) and re-project the registry; purge the endpoint's lifecycle state.
|
|
925
|
+
*/
|
|
926
|
+
export async function removeRuntimeCli(
|
|
927
|
+
runtime: string,
|
|
928
|
+
opts: CliEnvOptions & { peer: string; force?: boolean },
|
|
929
|
+
): Promise<RemoveRuntimeOutcome> {
|
|
930
|
+
const env = opts.env ?? process.env
|
|
931
|
+
const personality = opts.peer
|
|
932
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
933
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
934
|
+
if (!peer.runtimes.includes(runtime)) return { personality, runtime, action: 'absent', reason: `"${personality}" does not declare ${runtime}` }
|
|
935
|
+
if (isInfraRuntime(runtime)) {
|
|
936
|
+
return { personality, runtime, action: 'refused-infra', reason: `"${runtime}" is an always-on channel (launchd plist) — not supported by remove-runtime yet` }
|
|
937
|
+
}
|
|
938
|
+
if (peer.runtimes.length === 1) {
|
|
939
|
+
return { personality, runtime, action: 'refused-last', reason: `${runtime} is the only runtime of "${personality}" — use \`iapeer remove ${personality}\`` }
|
|
940
|
+
}
|
|
941
|
+
if (peer.runtime === runtime) {
|
|
942
|
+
return {
|
|
943
|
+
personality,
|
|
944
|
+
runtime,
|
|
945
|
+
action: 'refused-default',
|
|
946
|
+
reason: `${runtime} is the default runtime of "${personality}" — switch it first: iapeer default-runtime <other> --peer ${personality}`,
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const cfg = loadLifecycleConfig(env)
|
|
950
|
+
const identity = buildProcessAddress(runtime as Runtime, personality)
|
|
951
|
+
const steps: string[] = []
|
|
952
|
+
if (isPeerLive(runtime, personality, cfg.sockDir, env)) {
|
|
953
|
+
if (!opts.force) {
|
|
954
|
+
return { personality, runtime, action: 'refused-live', reason: `${identity} is LIVE — stop it first (iapeer stop ${personality} ${runtime}) or pass --force` }
|
|
955
|
+
}
|
|
956
|
+
const { killPtyHost } = await import('../launch/ptyHost.ts')
|
|
957
|
+
killPtyHost(identity, env)
|
|
958
|
+
steps.push('live session stopped (--force)')
|
|
959
|
+
}
|
|
960
|
+
// Slots for THIS endpoint (provider + capabilities), while the peer's state is still consistent.
|
|
961
|
+
try {
|
|
962
|
+
const slot = readMemoryProvider(env)
|
|
963
|
+
if (slot?.unprovision) {
|
|
964
|
+
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
965
|
+
const o = runProvisionCommand({ block: slot.unprovision, cwd: peer.cwd, runtime, personality, occasion: 'remove', env })
|
|
966
|
+
steps.push(`memory unprovision: ${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
|
|
967
|
+
}
|
|
968
|
+
} catch (e) {
|
|
969
|
+
steps.push(`memory unprovision: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
const { runCapabilityLifecycle } = await import('../capability/index.ts')
|
|
973
|
+
for (const run of runCapabilityLifecycle({ kind: 'unprovision', personality, cwd: peer.cwd, runtimes: [runtime], occasion: 'remove', env })) {
|
|
974
|
+
steps.push(`capability ${run.capability} unprovision: ${run.outcome.state}`)
|
|
975
|
+
}
|
|
976
|
+
} catch (e) {
|
|
977
|
+
steps.push(`capabilities: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
978
|
+
}
|
|
979
|
+
if (runtime === 'opencode') {
|
|
980
|
+
const { opencodePaths } = await import('../opencode/config.ts')
|
|
981
|
+
const dir = opencodePaths(personality, { env }).dir
|
|
982
|
+
if (existsSync(dir)) {
|
|
983
|
+
rmSync(dir, { recursive: true, force: true })
|
|
984
|
+
steps.push(`opencode private dir removed: ${dir}`)
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (runtime === 'codex') {
|
|
988
|
+
try {
|
|
989
|
+
const { removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
|
|
990
|
+
steps.push(`codex cwd trust: ${removeCodexCwdTrust(peer.cwd, env).state}`)
|
|
991
|
+
} catch (e) {
|
|
992
|
+
steps.push(`codex cwd trust: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '')
|
|
996
|
+
for (const marker of [join(peer.cwd, `.${runtime}`), join(peer.cwd, '.iapeer', 'runtimes', runtime)]) {
|
|
997
|
+
if (!existsSync(marker)) continue
|
|
998
|
+
const parked = `${marker}.removed-${stamp}`
|
|
999
|
+
renameSync(marker, parked)
|
|
1000
|
+
steps.push(`marker parked (data kept): ${parked}`)
|
|
1001
|
+
}
|
|
1002
|
+
const profile = readPeerProfile(peer.cwd)
|
|
1003
|
+
if (profile) {
|
|
1004
|
+
writePeerProfileAtomic(peer.cwd, { ...profile, runtimes: profile.runtimes.filter(r => r !== runtime) })
|
|
1005
|
+
steps.push('local profile: runtime removed')
|
|
1006
|
+
}
|
|
1007
|
+
const { reindexFromLocals } = await import('../identity/profileStandard.ts')
|
|
1008
|
+
await reindexFromLocals({ env })
|
|
1009
|
+
const purged = purgeIdentityState(cfg, identity)
|
|
1010
|
+
if (purged.length) steps.push(`lifecycle state purged: ${purged.join(', ')}`)
|
|
1011
|
+
return { personality, runtime, action: 'removed', steps }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
903
1014
|
export interface RemoveOutcome {
|
|
904
1015
|
personality: string
|
|
905
1016
|
action: 'removed' | 'absent' | 'refused-live' | 'refused-foreign-launchd'
|
|
@@ -1605,6 +1716,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1605
1716
|
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
|
|
1606
1717
|
desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), install activates it (installs SwiftBar when absent). See docs/16.',
|
|
1607
1718
|
},
|
|
1719
|
+
{ sig: 'remove-runtime <runtime> --peer <p> [--force]', desc: 'detach ONE agentic runtime from a peer (the undo of add-runtime): unprovision memory + capabilities for it, runtime cleanup (opencode private dir, codex trust), park its cwd markers as *.removed-<ts> (data kept), drop it from the profile + registry, purge its lifecycle state. Refuses the default runtime, the last runtime, a live session (unless --force) and infra channels' },
|
|
1608
1720
|
{ sig: 'private-dir <peer> [--runtime r]', desc: 'print the core-owned private directory of a peer endpoint (docs/20: opencode journal/state/fragments). The ONLY way a process not launched by the core gets the path — never derive the layout. Without --runtime: the single declared runtime that has one. Exit 1 when none' },
|
|
1609
1721
|
{ sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
|
|
1610
1722
|
{ sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
|
|
@@ -2310,6 +2422,15 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2310
2422
|
out(`${endpointPrivateDir(candidates[0]!, name, { env })}\n`)
|
|
2311
2423
|
return 0
|
|
2312
2424
|
}
|
|
2425
|
+
case 'remove-runtime': {
|
|
2426
|
+
const rt = positionals[0]
|
|
2427
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
2428
|
+
if (!rt || !peerName) return argErr(errOut, 'remove-runtime needs a runtime and a peer — usage: iapeer remove-runtime <runtime> --peer <p> [--force]')
|
|
2429
|
+
const o = await removeRuntimeCli(rt, { peer: peerName, force: flags.force === true, env })
|
|
2430
|
+
out(`${o.personality}: ${o.runtime} ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
2431
|
+
for (const step of o.steps ?? []) out(` ${step}\n`)
|
|
2432
|
+
return o.action === 'removed' || o.action === 'absent' ? 0 : 1
|
|
2433
|
+
}
|
|
2313
2434
|
case 'default-runtime': {
|
|
2314
2435
|
// The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
|
|
2315
2436
|
const rt = positionals[0]
|
package/src/daemon/main.ts
CHANGED
|
@@ -56,6 +56,7 @@ import type { ResolvedCaller } from '../identity/index.ts'
|
|
|
56
56
|
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
57
57
|
import {
|
|
58
58
|
createComposerDeliveryQueue,
|
|
59
|
+
deliveryLanded,
|
|
59
60
|
deliveryProcessed,
|
|
60
61
|
routeSend,
|
|
61
62
|
type InflightNote,
|
|
@@ -265,8 +266,10 @@ export function makeNoteInflight(cfg: LifecycleConfig): (note: InflightNote) =>
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
/** The redelivery prefix — the model sees it is a re-send, not a duplicate of something it answered. */
|
|
269
|
+
const REDELIVERY_PREFIX = '[redelivered by iapeer:'
|
|
268
270
|
export function redeliveryMessage(e: InflightEntry): string {
|
|
269
|
-
|
|
271
|
+
const why = e.unconfirmed ? 'it never reached the receiving session' : 'the receiving session ended before answering this message'
|
|
272
|
+
return `${REDELIVERY_PREFIX} ${why}; originally sent ${e.sentAt}]\n${e.message}`
|
|
270
273
|
}
|
|
271
274
|
|
|
272
275
|
/** Body of the explicit non-delivery notice to the original sender. */
|
|
@@ -289,7 +292,7 @@ export async function processInflight(
|
|
|
289
292
|
cfg: LifecycleConfig,
|
|
290
293
|
env: NodeJS.ProcessEnv,
|
|
291
294
|
routeDeps: RouteDeps,
|
|
292
|
-
seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean } = {},
|
|
295
|
+
seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean } = {},
|
|
293
296
|
): Promise<void> {
|
|
294
297
|
const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
|
|
295
298
|
const send = seam.send ?? routeSend
|
|
@@ -297,6 +300,7 @@ export async function processInflight(
|
|
|
297
300
|
const n = sweepInflight(cfg.stateDir, identity, {
|
|
298
301
|
sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
|
|
299
302
|
processed,
|
|
303
|
+
landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
|
|
300
304
|
})
|
|
301
305
|
if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
|
|
302
306
|
}
|
|
@@ -305,6 +309,9 @@ export async function processInflight(
|
|
|
305
309
|
const index = readPeersIndex({ env })
|
|
306
310
|
let reason: string
|
|
307
311
|
try {
|
|
312
|
+
// ONE redelivery per message: an already-redelivered message that orphans again goes to the
|
|
313
|
+
// sender as an explicit notice instead of looping.
|
|
314
|
+
if (e.message.startsWith(REDELIVERY_PREFIX)) throw new Error('already redelivered once and still not processed')
|
|
308
315
|
const caller = resolveCallerIdentity({ personality: e.caller.personality, runtime: e.caller.runtime }, index)
|
|
309
316
|
const r = await send(
|
|
310
317
|
caller,
|
|
@@ -32,8 +32,14 @@ export interface InflightEntry {
|
|
|
32
32
|
attachments?: string[]
|
|
33
33
|
/** The presented envelope (what the model sees) — the processed-check needle source. */
|
|
34
34
|
rendered: string
|
|
35
|
+
/** The live confirm window ran out at delivery: whether it LANDED is still unknown. */
|
|
36
|
+
unconfirmed?: true
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** How long an unconfirmed delivery may stay unlanded in a live session before the ledger treats the
|
|
40
|
+
* paste as swallowed and redelivers it (once). Generous: a busy receiver records a queued message late. */
|
|
41
|
+
export const UNLANDED_AFTER_MS = 120_000
|
|
42
|
+
|
|
37
43
|
const inflightPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.inflight.jsonl`)
|
|
38
44
|
const orphansPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.orphans.jsonl`)
|
|
39
45
|
|
|
@@ -84,6 +90,9 @@ export interface InflightSweepDeps {
|
|
|
84
90
|
sessionToken: (identity: string) => string | null
|
|
85
91
|
/** Did the receiving session answer this entry? */
|
|
86
92
|
processed: (entry: InflightEntry) => boolean
|
|
93
|
+
/** Did the entry land in the session at all? Only consulted for `unconfirmed` entries. */
|
|
94
|
+
landed?: (entry: InflightEntry) => boolean
|
|
95
|
+
now?: number
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
/**
|
|
@@ -105,8 +114,23 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
|
|
|
105
114
|
done = false
|
|
106
115
|
}
|
|
107
116
|
if (done) continue
|
|
108
|
-
if (current !== null && current === e.sessionToken)
|
|
109
|
-
|
|
117
|
+
if (current !== null && current === e.sessionToken) {
|
|
118
|
+
// Unknown landing (confirm window ran out): once seen in the session it is a normal pending entry;
|
|
119
|
+
// still unseen after UNLANDED_AFTER_MS → the paste was swallowed → orphan (redeliver once).
|
|
120
|
+
if (e.unconfirmed && deps.landed) {
|
|
121
|
+
let landed = false
|
|
122
|
+
try {
|
|
123
|
+
landed = deps.landed(e)
|
|
124
|
+
} catch {
|
|
125
|
+
landed = false
|
|
126
|
+
}
|
|
127
|
+
if (landed) keep.push({ ...e, unconfirmed: undefined })
|
|
128
|
+
else if ((deps.now ?? Date.now()) - e.ts > UNLANDED_AFTER_MS) orphaned.push(e)
|
|
129
|
+
else keep.push(e)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
keep.push(e)
|
|
133
|
+
} else orphaned.push(e)
|
|
110
134
|
}
|
|
111
135
|
writeJsonl(inflightPath(stateDir, identity), keep)
|
|
112
136
|
if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
|
package/src/transport/index.ts
CHANGED
|
@@ -294,6 +294,13 @@ export interface WarmDeliverSeam {
|
|
|
294
294
|
* the real `resubmitHosted` (a bare CR over the control channel). Tests inject a spy to assert the
|
|
295
295
|
* retry fires only while unconfirmed — and never once the record is in. */
|
|
296
296
|
resubmit?: (identity: string) => Promise<void>
|
|
297
|
+
/** UNKNOWN ≠ FAILED (incident 18.10: a busy receiver's transcript record landed after the grace,
|
|
298
|
+
* the sender got «NOT delivered», believed it and sent a duplicate). When set, a confirm grace that
|
|
299
|
+
* runs out on a LIVE session is reported as UNCONFIRMED — `onUnconfirmed()` fires and the delivery
|
|
300
|
+
* returns ok — and the inflight ledger, not the sender, owns any retry (docs/20 §Durable inbound).
|
|
301
|
+
* Unset (default): the strict legacy false-FAIL. */
|
|
302
|
+
allowUnconfirmed?: boolean
|
|
303
|
+
onUnconfirmed?: () => void
|
|
297
304
|
}
|
|
298
305
|
|
|
299
306
|
/**
|
|
@@ -445,6 +452,10 @@ async function deliverViaHost(
|
|
|
445
452
|
}
|
|
446
453
|
await sleep(LIVENESS_POLL_MS)
|
|
447
454
|
}
|
|
455
|
+
if (seam.allowUnconfirmed) {
|
|
456
|
+
seam.onUnconfirmed?.()
|
|
457
|
+
return ok(undefined)
|
|
458
|
+
}
|
|
448
459
|
return err(
|
|
449
460
|
`hosted peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
450
461
|
`(no transcript record carrying the message within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
@@ -892,6 +903,36 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
|
|
|
892
903
|
return false
|
|
893
904
|
}
|
|
894
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Did the envelope LAND in the receiving session at all (any record carrying it, including claude's
|
|
908
|
+
* queue-operation for a busy session)? Weaker than deliveryProcessed — it separates «queued / being
|
|
909
|
+
* worked on» from «the paste was swallowed and nothing will ever see it». Structural runtimes (opencode)
|
|
910
|
+
* always landed: their delivery ack is a native persistence ack.
|
|
911
|
+
*/
|
|
912
|
+
export function deliveryLanded(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
913
|
+
if (getAdapter(probe.runtime).deliveryConfirm === 'structural') return true
|
|
914
|
+
const needles = confirmNeedles(probe.rendered)
|
|
915
|
+
if (!needles.length || needles.every(n => !n)) return false
|
|
916
|
+
for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
|
|
917
|
+
let st
|
|
918
|
+
try {
|
|
919
|
+
st = statSync(path)
|
|
920
|
+
} catch {
|
|
921
|
+
continue
|
|
922
|
+
}
|
|
923
|
+
if (st.mtimeMs < probe.ts - 5000) continue
|
|
924
|
+
for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
|
|
925
|
+
if (!line.trim()) continue
|
|
926
|
+
try {
|
|
927
|
+
if (jsonStringValuesContainAll(JSON.parse(line), needles)) return true
|
|
928
|
+
} catch {
|
|
929
|
+
/* skip */
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return false
|
|
934
|
+
}
|
|
935
|
+
|
|
895
936
|
/** One confirmed delivery to an AGENT endpoint, as transport reports it to the inflight ledger. */
|
|
896
937
|
export interface InflightNote {
|
|
897
938
|
sentAt: string
|
|
@@ -905,6 +946,8 @@ export interface InflightNote {
|
|
|
905
946
|
topic?: string
|
|
906
947
|
attachments?: string[]
|
|
907
948
|
rendered: string
|
|
949
|
+
/** The live confirm window ran out (see WarmDeliverSeam.allowUnconfirmed). */
|
|
950
|
+
unconfirmed?: true
|
|
908
951
|
}
|
|
909
952
|
|
|
910
953
|
function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
@@ -1144,6 +1187,10 @@ export interface RouteResult {
|
|
|
1144
1187
|
queuedBy?: 'ephemeral' | 'composer'
|
|
1145
1188
|
/** Queue depth right after the enqueue (observability; only with queued). */
|
|
1146
1189
|
queueDepth?: number
|
|
1190
|
+
/** The bytes reached the live session but its acceptance record did not appear within the confirm
|
|
1191
|
+
* window (e.g. a busy receiver). NOT a failure: do not resend — the core's inflight ledger checks
|
|
1192
|
+
* it landed and redelivers once (or tells you) if it did not. */
|
|
1193
|
+
unconfirmed?: true
|
|
1147
1194
|
}
|
|
1148
1195
|
|
|
1149
1196
|
// WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
|
|
@@ -1293,7 +1340,7 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
|
|
|
1293
1340
|
}
|
|
1294
1341
|
}
|
|
1295
1342
|
|
|
1296
|
-
type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string }
|
|
1343
|
+
type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string; unconfirmed?: boolean }
|
|
1297
1344
|
|
|
1298
1345
|
/** Best-effort inflight record (OC07) — only for agent (tui) endpoints. */
|
|
1299
1346
|
function noteInflight(
|
|
@@ -1318,6 +1365,7 @@ function noteInflight(
|
|
|
1318
1365
|
...(info.topic ? { topic: info.topic } : {}),
|
|
1319
1366
|
...(info.attachments?.length ? { attachments: info.attachments } : {}),
|
|
1320
1367
|
rendered: renderEnvelopeForAgent(info.envelope),
|
|
1368
|
+
...(info.unconfirmed ? { unconfirmed: true as const } : {}),
|
|
1321
1369
|
})
|
|
1322
1370
|
} catch {
|
|
1323
1371
|
/* a post-delivery hook must never fail the delivery */
|
|
@@ -1790,16 +1838,26 @@ async function routeSendInner(
|
|
|
1790
1838
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
1791
1839
|
// deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
|
|
1792
1840
|
// socket, any other target keeps the tmux path. Flag-off → byte-identical to deliverViaTmux.
|
|
1793
|
-
|
|
1841
|
+
// Unknown ≠ failed: a confirm window that runs out on a LIVE session is reported unconfirmed and the
|
|
1842
|
+
// inflight ledger owns the retry — only when that ledger is wired (deps.noteInflight), else strict.
|
|
1843
|
+
let unconfirmed = false
|
|
1844
|
+
const delivered = await deliverWarm(
|
|
1845
|
+
target.value,
|
|
1846
|
+
envelope,
|
|
1847
|
+
peer.cwd,
|
|
1848
|
+
deps.noteInflight ? { allowUnconfirmed: true, onUnconfirmed: () => (unconfirmed = true) } : {},
|
|
1849
|
+
env,
|
|
1850
|
+
)
|
|
1794
1851
|
if (!delivered.ok) return delivered
|
|
1795
1852
|
noteTopic(deps, target.value.address, topic)
|
|
1796
1853
|
noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
|
|
1797
|
-
noteInflight(deps, target.value, peer.cwd, inflightInfo, env)
|
|
1854
|
+
noteInflight(deps, target.value, peer.cwd, { ...inflightInfo, unconfirmed }, env)
|
|
1798
1855
|
return ok({
|
|
1799
1856
|
ok: true,
|
|
1800
1857
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
1801
1858
|
woke: false,
|
|
1802
1859
|
ts: sentAt,
|
|
1860
|
+
...(unconfirmed ? { unconfirmed: true as const } : {}),
|
|
1803
1861
|
})
|
|
1804
1862
|
}
|
|
1805
1863
|
|