@agfpd/iapeer 0.4.116 → 0.4.118
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/15-fleet-api.md +1 -0
- package/docs/20-opencode.md +43 -0
- package/package.json +1 -1
- package/src/cli/index.ts +4 -1
- package/src/daemon/fleet.ts +9 -2
- package/src/daemon/index.ts +7 -1
- package/src/daemon/main.ts +123 -1
- package/src/init/index.ts +20 -0
- package/src/launch/adapters/opencode.ts +5 -2
- package/src/launch/index.ts +1 -0
- package/src/launch/types.ts +2 -0
- package/src/lifecycle/inflight.ts +138 -0
- package/src/opencode/config.ts +38 -0
- package/src/opencode/iapeer-plugin.mjs +1 -0
- package/src/opencode/journal.ts +2 -0
- package/src/supervisor/daemon.ts +28 -17
- package/src/transport/index.ts +153 -3
package/docs/15-fleet-api.md
CHANGED
|
@@ -80,6 +80,7 @@ Field notes:
|
|
|
80
80
|
- `runtimes[].status` — `live` (session up) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `○` / `✕`.
|
|
81
81
|
- `runtimes[].agent` — **agent-endpoint discovery**: `true` iff this declared runtime is an agent runtime the running core can host (listed in `host.agent_runtimes`). A channel that offers a personality's agent endpoints (a Telegram menu, a web picker) offers exactly its `agent: true` runtimes — never a consumer-side runtime list, never a `which`/PATH probe. A declared runtime the core cannot host (a newer peer profile on an older core) is `false` and is not offered. A channel runtime (`telegram`, `web`, …) is always `false`. Additive: a pre-discovery daemon omits it — clients MUST then fall back to treating no runtime as discoverable rather than guessing. Which endpoint a send reaches is a separate rule: an omitted runtime is always `default_runtime` (config-anchored, never «the live one»); a control action for the same dialogue must name the same runtime its send used.
|
|
82
82
|
- `runtimes[].private_dir` — the core-owned private directory of this endpoint (docs/20: the OpenCode journal, state and provider fragments), present only for a runtime that has one (today `opencode`). An observer that the core does not launch (a console, a collector) reads the path HERE or from `iapeer private-dir <peer> [--runtime r]` — it never derives the layout from the root. Additive.
|
|
83
|
+
- `runtimes[].model` — the `provider/model` the endpoint ACTUALLY used for its last assistant turn, as the runtime itself reports it (today `opencode`, from its state; docs/20 §Provider pinning). Absent until a first turn. Lets an operator see an implicit default provider. Additive.
|
|
83
84
|
- `host.agent_runtimes` — the agent runtimes this core build can host, in preference order. It grows when an agent adapter ships (e.g. `opencode`, docs/20).
|
|
84
85
|
- `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
|
|
85
86
|
- `runtimes[].activity` — **turn activity**: is this runtime *in a turn* right now. See «Activity» below. Additive; absent on a pre-activity daemon, on every non-`live` runtime, and whenever the daemon has no evidence — clients MUST treat absence as **no claim**, never as `idle`.
|
package/docs/20-opencode.md
CHANGED
|
@@ -123,6 +123,49 @@ Open core contracts this ABI depends on:
|
|
|
123
123
|
provision/unprovision per peer and runtime, like the memory slot. Until it
|
|
124
124
|
exists, a manual install is not reported as final.
|
|
125
125
|
|
|
126
|
+
## Provider pinning
|
|
127
|
+
|
|
128
|
+
Without an explicit `model`, native OpenCode uses its own hosted default
|
|
129
|
+
(`opencode/big-pickle`), and the composed doctrine, registry and messages go
|
|
130
|
+
there. Owner policy (2026-10-18): this is allowed, but never silent.
|
|
131
|
+
- No explicit `model` (`<provider>/<model>` in the peer's `opencode.json` or the
|
|
132
|
+
inline `OPENCODE_CONFIG_CONTENT`): the launch proceeds, `lifecycle.log` records
|
|
133
|
+
`ev=launch-warning warning="opencode-implicit-provider: …"`, and
|
|
134
|
+
`add-runtime`/`create` warn with the file to fill.
|
|
135
|
+
- An explicit `model` is pinned: `small_model` defaults to `model` (titles and
|
|
136
|
+
summaries never drift to another provider), `enabled_providers` defaults to
|
|
137
|
+
exactly the providers of those two; an explicit list that excludes them refuses
|
|
138
|
+
the launch.
|
|
139
|
+
- The model actually used is reported by the plugin (`state.json` `model`) and
|
|
140
|
+
appears as the fleet snapshot field `runtimes[].model` (docs/15).
|
|
141
|
+
|
|
142
|
+
## Durable inbound across a session death (OC07, all agent runtimes)
|
|
143
|
+
|
|
144
|
+
Invariant: an inbound the core acknowledged is either processed by a later
|
|
145
|
+
session, or its sender is told explicitly that it was not. A runtime's input
|
|
146
|
+
queue lives in its process. Measured 2026-10-18 on claude: a message acked during
|
|
147
|
+
a busy turn had its `user` record (and the API request) in the transcript, the
|
|
148
|
+
process was SIGKILLed before any answer, and the fresh next session never saw it.
|
|
149
|
+
|
|
150
|
+
- Every confirmed delivery to an agent endpoint (live, wake, launchd-revive,
|
|
151
|
+
composer queue) is recorded in `<stateDir>/<identity>.inflight.jsonl` with the
|
|
152
|
+
hosting session token.
|
|
153
|
+
- «Processed» means a model response after the record carrying the envelope:
|
|
154
|
+
claude — an `assistant` record after a non-`queue-operation` record; codex — a
|
|
155
|
+
non-user `response_item` or `agent_message`; opencode — an assistant message
|
|
156
|
+
whose `parentID` is that user message. «Recorded» alone is not processed.
|
|
157
|
+
- After each supervise tick, processed entries leave. Unprocessed entries whose
|
|
158
|
+
session is gone or was replaced become orphans and are redelivered through the
|
|
159
|
+
normal route as their original sender, prefixed `[redelivered by iapeer: …]`
|
|
160
|
+
(wake-on-miss and the crash-loop guard apply). If redelivery fails, the sender
|
|
161
|
+
receives an explicit notice from `iapeer`. Logged as `ev=inflight-orphaned`
|
|
162
|
+
(lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
|
|
163
|
+
(delivery.log).
|
|
164
|
+
- A false «not processed» costs a visible duplicate, never a silent loss.
|
|
165
|
+
|
|
166
|
+
The real death cause is recorded too: the supervisor waits for the reap, so a
|
|
167
|
+
SIGKILL is logged as `dead_signal=SIGKILL`, never as a clean exit 0.
|
|
168
|
+
|
|
126
169
|
## Acceptance matrix
|
|
127
170
|
|
|
128
171
|
`OPEN` includes implementation and/or missing live proof. Unit tests, config-file
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -2735,7 +2735,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2735
2735
|
` docs: ${docs.copied ? docs.dest : `skipped (${docs.reason})`}\n` +
|
|
2736
2736
|
trayLine +
|
|
2737
2737
|
` daemon plist ${plistChanged ? 'written' : 'unchanged (byte-identical — no write, no BTM notification)'}: ${plist}\n` +
|
|
2738
|
-
|
|
2738
|
+
(plistChanged
|
|
2739
|
+
? ` (install does not load it: \`iapeer update\` re-registers the daemon itself (bootout → bootstrap) right after this step; ` +
|
|
2740
|
+
`a standalone install loads it with \`iapeer onboard\` or launchctl bootstrap gui/$(id -u) ${plist})\n`
|
|
2741
|
+
: ''),
|
|
2739
2742
|
)
|
|
2740
2743
|
return 0
|
|
2741
2744
|
}
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -35,9 +35,11 @@
|
|
|
35
35
|
|
|
36
36
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
37
37
|
import { closeSync, openSync, readSync, statSync } from 'fs'
|
|
38
|
+
import { join } from 'path'
|
|
38
39
|
import { isSupportedLocalRuntime, SUPPORTED_LOCAL_RUNTIMES } from '../core/constants.ts'
|
|
39
40
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
40
41
|
import { endpointPrivateDir } from '../storage/index.ts'
|
|
42
|
+
import { readOpencodeState } from '../opencode/journal.ts'
|
|
41
43
|
import { IAPEER_VERSION } from '../core/version.ts'
|
|
42
44
|
import {
|
|
43
45
|
ephemeralQueueDepth,
|
|
@@ -89,6 +91,9 @@ export interface FleetRuntimeStatus {
|
|
|
89
91
|
/** Core-owned private directory of this endpoint (docs/20: opencode journal/state). Absent when the
|
|
90
92
|
* runtime has none. Observers read it from here; they never derive the layout. */
|
|
91
93
|
private_dir?: string
|
|
94
|
+
/** The model the runtime ACTUALLY used last (`provider/model`, from the endpoint's own state; docs/20
|
|
95
|
+
* §Provider pinning). Absent until a first assistant turn, or for runtimes that do not report it. */
|
|
96
|
+
model?: string
|
|
92
97
|
/** Present (true) iff a HUMAN operator is attached to this live hosted session. */
|
|
93
98
|
attached?: boolean
|
|
94
99
|
/** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
|
|
@@ -240,9 +245,11 @@ export function buildFleetSnapshot(
|
|
|
240
245
|
* never emitted, which is exactly its "no claim" semantics. */
|
|
241
246
|
activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
|
|
242
247
|
): FleetSnapshot {
|
|
243
|
-
const privateDirOf = (runtime: string, personality: string): { private_dir?: string } => {
|
|
248
|
+
const privateDirOf = (runtime: string, personality: string): { private_dir?: string; model?: string } => {
|
|
244
249
|
const d = endpointPrivateDir(runtime, personality, { env })
|
|
245
|
-
|
|
250
|
+
if (!d) return {}
|
|
251
|
+
const model = (readOpencodeState(join(d, 'state.json')) as { model?: unknown } | null)?.model
|
|
252
|
+
return { private_dir: d, ...(typeof model === 'string' && model ? { model } : {}) }
|
|
246
253
|
}
|
|
247
254
|
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
248
255
|
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
package/src/daemon/index.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { IAPEER_VERSION } from '../core/version.ts'
|
|
|
45
45
|
import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
|
|
46
46
|
import { readPeersIndex, type PeersIndex } from '../registry/index.ts'
|
|
47
47
|
import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
|
|
48
|
-
import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
|
|
48
|
+
import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type RouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
|
|
49
49
|
import { appendDeliveryEvent } from './deliverylog.ts'
|
|
50
50
|
|
|
51
51
|
export const CALLER_HEADER = 'x-iapeer-identity'
|
|
@@ -226,6 +226,8 @@ export interface CallToolDeps {
|
|
|
226
226
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
227
227
|
/** В7 — confirmed-delivery stamp → the target's idle-proxy floor (see transport.RouteDeps.noteDelivered). */
|
|
228
228
|
noteDelivered?: (identity: string) => void
|
|
229
|
+
/** OC07 — confirmed delivery to an agent endpoint → the inflight ledger (see transport.RouteDeps.noteInflight). */
|
|
230
|
+
noteInflight?: RouteDeps['noteInflight']
|
|
229
231
|
/** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
|
|
230
232
|
composerQueue?: ComposerQueueRouteDeps
|
|
231
233
|
/** H4 launchd-managed detector — see transport.RouteDeps.isLaunchdManaged (the
|
|
@@ -255,6 +257,7 @@ export async function callTool(
|
|
|
255
257
|
ephemeral: deps.ephemeral,
|
|
256
258
|
noteLiveTopic: deps.noteLiveTopic,
|
|
257
259
|
noteDelivered: deps.noteDelivered,
|
|
260
|
+
noteInflight: deps.noteInflight,
|
|
258
261
|
composerQueue: deps.composerQueue,
|
|
259
262
|
isLaunchdManaged: deps.isLaunchdManaged,
|
|
260
263
|
})
|
|
@@ -466,6 +469,8 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
466
469
|
/** В7 — confirmed-delivery stamp → the target's idle-proxy floor (setLastDelivered). OFF by default;
|
|
467
470
|
* the production main wires it. */
|
|
468
471
|
noteDelivered?: (identity: string) => void
|
|
472
|
+
/** OC07 — confirmed delivery to an agent endpoint → the inflight ledger (see transport.RouteDeps.noteInflight). */
|
|
473
|
+
noteInflight?: RouteDeps['noteInflight']
|
|
469
474
|
/**
|
|
470
475
|
* Busy-human-composer queue — async acceptance for live local TUI delivery when
|
|
471
476
|
* an attached operator has non-dim text in the target composer. OFF by default
|
|
@@ -510,6 +515,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
510
515
|
ephemeral: opts.ephemeral,
|
|
511
516
|
noteLiveTopic: opts.noteLiveTopic,
|
|
512
517
|
noteDelivered: opts.noteDelivered,
|
|
518
|
+
noteInflight: opts.noteInflight,
|
|
513
519
|
composerQueue: opts.composerQueue,
|
|
514
520
|
isLaunchdManaged: opts.isLaunchdManaged,
|
|
515
521
|
}
|
package/src/daemon/main.ts
CHANGED
|
@@ -56,7 +56,10 @@ import type { ResolvedCaller } from '../identity/index.ts'
|
|
|
56
56
|
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
57
57
|
import {
|
|
58
58
|
createComposerDeliveryQueue,
|
|
59
|
+
deliveryProcessed,
|
|
59
60
|
routeSend,
|
|
61
|
+
type InflightNote,
|
|
62
|
+
type RouteDeps,
|
|
60
63
|
type ComposerQueueRouteDeps,
|
|
61
64
|
type ComposerQueuedDelivery,
|
|
62
65
|
type EphemeralRouteDeps,
|
|
@@ -73,6 +76,9 @@ import { DEFAULT_PROFILE_SYNC_INTERVAL_MS, startProfileSync } from './profilesyn
|
|
|
73
76
|
import { startGoalWatch } from './goalwatch.ts'
|
|
74
77
|
import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
|
|
75
78
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
79
|
+
import { randomBytes } from 'crypto'
|
|
80
|
+
import { listInflightIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
|
|
81
|
+
import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
|
|
76
82
|
|
|
77
83
|
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
78
84
|
* (claude/codex `--transport http <url>`) bind to a fixed URL, so production needs
|
|
@@ -249,6 +255,88 @@ function queuedFailureMessage(job: ComposerQueuedDelivery, reason: string): stri
|
|
|
249
255
|
* 2) before daemon shutdown/restart, fail every pending queued envelope back
|
|
250
256
|
* to its sender, so `queued` never becomes silent loss.
|
|
251
257
|
*/
|
|
258
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
259
|
+
// OC07 — durable inbound across a session death (lifecycle/inflight.ts)
|
|
260
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
/** The transport hook → the inflight ledger. Best-effort by contract (transport swallows throws). */
|
|
263
|
+
export function makeNoteInflight(cfg: LifecycleConfig): (note: InflightNote) => void {
|
|
264
|
+
return note => recordInflight(cfg.stateDir, { ...note, id: randomBytes(6).toString('hex'), ts: Date.now() })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The redelivery prefix — the model sees it is a re-send, not a duplicate of something it answered. */
|
|
268
|
+
export function redeliveryMessage(e: InflightEntry): string {
|
|
269
|
+
return `[redelivered by iapeer: the receiving session ended before answering this message; originally sent ${e.sentAt}]\n${e.message}`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Body of the explicit non-delivery notice to the original sender. */
|
|
273
|
+
export function undeliveredNotice(e: InflightEntry, reason: string): string {
|
|
274
|
+
return (
|
|
275
|
+
`iapeer: your message to "${e.personality}" (${e.runtime}), sent ${e.sentAt}${e.topic ? ` topic=${e.topic}` : ''}, ` +
|
|
276
|
+
`was acknowledged but NOT processed: the receiving session ended before answering it, and redelivery failed (${reason}). ` +
|
|
277
|
+
`Resend it if it still matters. First line: ${e.message.split('\n')[0]!.slice(0, 200)}`
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* One OC07 pass, run after every supervise tick. (1) Sweep every ledger: processed entries leave;
|
|
283
|
+
* unprocessed entries whose receiving session is gone or replaced become orphans. (2) Every orphan is
|
|
284
|
+
* redelivered through the normal route AS ITS ORIGINAL SENDER (wake-on-miss, guards, crash-loop guard
|
|
285
|
+
* all apply); if that fails, the sender gets an explicit notice from iapeer. Either way the orphan
|
|
286
|
+
* leaves the store — the invariant «processed by a later session, or the sender knows» holds.
|
|
287
|
+
*/
|
|
288
|
+
export async function processInflight(
|
|
289
|
+
cfg: LifecycleConfig,
|
|
290
|
+
env: NodeJS.ProcessEnv,
|
|
291
|
+
routeDeps: RouteDeps,
|
|
292
|
+
seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean } = {},
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
|
|
295
|
+
const send = seam.send ?? routeSend
|
|
296
|
+
for (const identity of listInflightIdentities(cfg.stateDir)) {
|
|
297
|
+
const n = sweepInflight(cfg.stateDir, identity, {
|
|
298
|
+
sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
|
|
299
|
+
processed,
|
|
300
|
+
})
|
|
301
|
+
if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
|
|
302
|
+
}
|
|
303
|
+
for (const identity of listOrphanIdentities(cfg.stateDir)) {
|
|
304
|
+
for (const e of takeOrphans(cfg.stateDir, identity)) {
|
|
305
|
+
const index = readPeersIndex({ env })
|
|
306
|
+
let reason: string
|
|
307
|
+
try {
|
|
308
|
+
const caller = resolveCallerIdentity({ personality: e.caller.personality, runtime: e.caller.runtime }, index)
|
|
309
|
+
const r = await send(
|
|
310
|
+
caller,
|
|
311
|
+
{ personality: e.personality, runtime: e.runtime, message: redeliveryMessage(e), ...(e.topic ? { topic: e.topic } : {}), ...(e.attachments?.length ? { attachments: e.attachments } : {}) },
|
|
312
|
+
routeDeps,
|
|
313
|
+
)
|
|
314
|
+
if (r.ok) {
|
|
315
|
+
appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-redelivered', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'true', woke: String(r.value.woke) })
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
reason = r.error.message
|
|
319
|
+
} catch (x) {
|
|
320
|
+
reason = x instanceof Error ? x.message : String(x)
|
|
321
|
+
}
|
|
322
|
+
// Redelivery failed → the sender must learn it explicitly.
|
|
323
|
+
let notified = 'false'
|
|
324
|
+
try {
|
|
325
|
+
const self = findPeer(index, 'iapeer')
|
|
326
|
+
const callerAddr = `${e.caller.runtime}-${e.caller.personality}`
|
|
327
|
+
if (self && callerAddr !== `${self.runtime}-iapeer`) {
|
|
328
|
+
const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
|
|
329
|
+
const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message: undeliveredNotice(e, reason), topic: e.topic ?? 'delivery-failed' }, routeDeps)
|
|
330
|
+
notified = String(n.ok)
|
|
331
|
+
}
|
|
332
|
+
} catch {
|
|
333
|
+
notified = 'false'
|
|
334
|
+
}
|
|
335
|
+
appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-undelivered', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'false', err: reason.slice(0, 300), notified })
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
252
340
|
export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): ComposerQueueRouteDeps {
|
|
253
341
|
const noteLiveTopic = makeNoteLiveTopic(cfg, env)
|
|
254
342
|
const onDelivered = makeArmEphemeralOnDelivered(cfg)
|
|
@@ -264,6 +352,7 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
|
|
|
264
352
|
logDir: cfg.logDir,
|
|
265
353
|
noteLiveTopic,
|
|
266
354
|
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
355
|
+
noteInflight: makeNoteInflight(cfg),
|
|
267
356
|
onDelivered,
|
|
268
357
|
notifyFailed: async (job, reason) => {
|
|
269
358
|
const index = readPeersIndex({ env })
|
|
@@ -373,6 +462,7 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
373
462
|
const armEphemeralOnDelivered = makeArmEphemeralOnDelivered(cfg)
|
|
374
463
|
const ephemeral = makeEphemeralRouteDeps(cfg, env)
|
|
375
464
|
const composerQueue = makeComposerQueueRouteDeps(cfg, env)
|
|
465
|
+
const noteInflight = makeNoteInflight(cfg)
|
|
376
466
|
// Notice board (docs/19) — ONE instance shared by the mute-watch (raises) and the
|
|
377
467
|
// fleet handler (serves /notices + the snapshot field). Constructed HERE, at the
|
|
378
468
|
// composition point, because that sharing is the whole wiring.
|
|
@@ -463,6 +553,8 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
463
553
|
// В7 — a confirmed live delivery floors the target's idle proxy (setLastDelivered), so a
|
|
464
554
|
// just-delivered message is never reaped away before its turn record lands.
|
|
465
555
|
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
556
|
+
// OC07 — confirmed delivery to an agent endpoint → inflight ledger (redelivered if its session dies first).
|
|
557
|
+
noteInflight,
|
|
466
558
|
// Busy-human-composer queue: fast queued ack, async drain, fail all pending
|
|
467
559
|
// on daemon close/update so a queued envelope is never silently lost.
|
|
468
560
|
composerQueue,
|
|
@@ -496,6 +588,16 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
496
588
|
// start (durable queue), and RETRIES a failed wake (the item stayed at the
|
|
497
589
|
// head). Identities without a queue or with a live session are no-ops.
|
|
498
590
|
await drainAllEphemeralQueues(cfg, { env })
|
|
591
|
+
// OC07 — acknowledged-but-unanswered inbound of a dead session: redeliver or tell the sender.
|
|
592
|
+
await processInflight(cfg, env, {
|
|
593
|
+
env,
|
|
594
|
+
wake: makeWakeFn(cfg, env),
|
|
595
|
+
ephemeral,
|
|
596
|
+
noteLiveTopic,
|
|
597
|
+
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
598
|
+
noteInflight,
|
|
599
|
+
isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
|
|
600
|
+
})
|
|
499
601
|
},
|
|
500
602
|
// Surface a supervise-tick throw that the daemon timer would otherwise SWALLOW (the class that
|
|
501
603
|
// hid a stuck reaper for hours): record ev=supervise-error in lifecycle.log, flattened to one
|
|
@@ -605,6 +707,14 @@ export interface InstallDaemonPlistResult {
|
|
|
605
707
|
* com.agfpd.* is the foundation-exclusive daemon namespace, so this never
|
|
606
708
|
* touches the com.iapeer.* persistent-peer fleet.
|
|
607
709
|
*/
|
|
710
|
+
/** The EnvironmentVariables.PATH value of a rendered plist (XML-unescaped), or undefined. */
|
|
711
|
+
export function plistEnvPath(xml: string): string | undefined {
|
|
712
|
+
const m = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(xml)
|
|
713
|
+
const v = m ? /<key>PATH<\/key>\s*<string>([\s\S]*?)<\/string>/.exec(m[1]!) : null
|
|
714
|
+
if (!v) return undefined
|
|
715
|
+
return v[1]!.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&')
|
|
716
|
+
}
|
|
717
|
+
|
|
608
718
|
export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): InstallDaemonPlistResult {
|
|
609
719
|
const env = opts.env ?? process.env
|
|
610
720
|
const path = daemonPlistPath(env)
|
|
@@ -614,7 +724,19 @@ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): Instal
|
|
|
614
724
|
`(no ownership sentinel) — another manager owns it`,
|
|
615
725
|
)
|
|
616
726
|
}
|
|
617
|
-
|
|
727
|
+
// PATH STABILITY: buildDaemonPlistSpec defaults PATH to the INVOKING process's env.PATH, so every
|
|
728
|
+
// `iapeer update` run from a different shell rewrote the plist (a BTM notification each time, and a
|
|
729
|
+
// daemon PATH that silently followed whoever deployed last). The first install fixes the PATH; later
|
|
730
|
+
// installs keep the one on disk unless a caller passes `path` explicitly.
|
|
731
|
+
let keepPath: string | undefined
|
|
732
|
+
if (opts.path === undefined) {
|
|
733
|
+
try {
|
|
734
|
+
keepPath = plistEnvPath(readFileSync(path, 'utf8'))
|
|
735
|
+
} catch {
|
|
736
|
+
keepPath = undefined
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const rendered = renderLaunchdPlist(buildDaemonPlistSpec(keepPath !== undefined ? { ...opts, path: keepPath } : opts))
|
|
618
740
|
mkdirSync(launchAgentsDir(env), { recursive: true })
|
|
619
741
|
mkdirSync(pluginLogsDir('iapeer', { env }), { recursive: true, mode: 0o700 })
|
|
620
742
|
// Write-if-changed: a byte-identical rewrite is a pure no-op for the daemon but
|
package/src/init/index.ts
CHANGED
|
@@ -554,6 +554,26 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
|
|
|
554
554
|
|
|
555
555
|
const doctrine = ensureDoctrineTemplate(provisioned.cwd)
|
|
556
556
|
|
|
557
|
+
// OpenCode never launches on an implicit provider (pinProvider). Say so at attach time, with the
|
|
558
|
+
// exact file to fill, instead of letting the first wake be the first signal.
|
|
559
|
+
if (provisioned.runtime === 'opencode') {
|
|
560
|
+
const { opencodePaths } = await import('../opencode/config.ts')
|
|
561
|
+
const cfgPath = opencodePaths(provisioned.personality, { env }).config
|
|
562
|
+
let model: unknown
|
|
563
|
+
try {
|
|
564
|
+
model = (JSON.parse(readFileSync(cfgPath, 'utf8')) as { model?: unknown }).model
|
|
565
|
+
} catch {
|
|
566
|
+
model = undefined
|
|
567
|
+
}
|
|
568
|
+
if (typeof model !== 'string' || !model.includes('/')) {
|
|
569
|
+
opts.warn?.(
|
|
570
|
+
`opencode provider not configured for "${provisioned.personality}": it will run on OpenCode's own default ` +
|
|
571
|
+
`provider (doctrine and messages go there) until ${cfgPath} names "model": "<provider>/<model>" ` +
|
|
572
|
+
`and that provider (docs/20 §Provider pinning)`,
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
557
577
|
// INFRA peer: per-peer runtime self-config, THEN auto-bootstrap. For an agentic
|
|
558
578
|
// (warm-on-demand) runtime neither applies — it is daemon-woken, never launchd-held,
|
|
559
579
|
// and gets its MCP wiring above instead.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { randomBytes } from 'node:crypto'
|
|
21
21
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
22
|
-
import { composeOpencodeConfig, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
|
|
22
|
+
import { composeOpencodeConfig, hasExplicitModel, OPENCODE_AGENT, OPENCODE_PLUGIN_SOURCE, opencodePaths, writeOpencodePlugin } from '../../opencode/config.ts'
|
|
23
23
|
import { opencodeControl } from '../../opencode/control.ts'
|
|
24
24
|
import { fileMtimeMs, readJournalFrom, readOpencodeState } from '../../opencode/journal.ts'
|
|
25
25
|
import { resolveDaemonMcpUrl } from '../../storage/index.ts'
|
|
@@ -105,7 +105,10 @@ export const opencodeAdapter: RuntimeAdapter = {
|
|
|
105
105
|
IAPEER_FLEET_URL: resolveFleetBase(env),
|
|
106
106
|
}
|
|
107
107
|
if (spec.resume && spec.resumeRef) out.IAPEER_OPENCODE_SESSION = spec.resumeRef
|
|
108
|
-
|
|
108
|
+
const warnings = hasExplicitModel(config)
|
|
109
|
+
? []
|
|
110
|
+
: [`opencode-implicit-provider: no explicit "model" in ${p.config} — OpenCode's own default provider receives the doctrine and messages`]
|
|
111
|
+
return { env: out, readyToken: launchID, warnings }
|
|
109
112
|
},
|
|
110
113
|
|
|
111
114
|
async structuralReady(spec, launchID, deadlineMs, alive, env) {
|
package/src/launch/index.ts
CHANGED
|
@@ -200,6 +200,7 @@ export const launch: LaunchFn = async (
|
|
|
200
200
|
const { PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY } = invocation.env
|
|
201
201
|
invocation.env = { ...invocation.env, ...prepared.env, PEER_PERSONALITY, PEER_RUNTIME, PEER_IDENTITY }
|
|
202
202
|
readyToken = prepared.readyToken
|
|
203
|
+
for (const w of prepared.warnings ?? []) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
|
|
203
204
|
} catch (e) {
|
|
204
205
|
return fail(identity, `launch preparation failed: ${e instanceof Error ? e.message : String(e)}`)
|
|
205
206
|
}
|
package/src/launch/types.ts
CHANGED
|
@@ -449,6 +449,8 @@ export interface ObserveContext {
|
|
|
449
449
|
export interface PreparedLaunch {
|
|
450
450
|
env: Record<string, string>
|
|
451
451
|
readyToken?: string
|
|
452
|
+
/** Non-fatal launch facts the primitive records in lifecycle.log (ev=launch-warning). */
|
|
453
|
+
warnings?: string[]
|
|
452
454
|
}
|
|
453
455
|
|
|
454
456
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Inflight ledger — the core's record of CONFIRMED deliveries a receiving session has not yet
|
|
2
|
+
// PROCESSED (docs/20 OC07; invariant: an inbound the core acknowledged is either processed by a later
|
|
3
|
+
// session or its sender is told explicitly it was not). A runtime's own input queue lives in its
|
|
4
|
+
// process: a session that dies (crash, SIGKILL, operator kill) before answering a queued message
|
|
5
|
+
// takes it along, and a fresh next session never sees it. The ledger survives that death.
|
|
6
|
+
//
|
|
7
|
+
// Lifecycle of an entry: written at delivery (transport → noteInflight) → pruned once the session
|
|
8
|
+
// answered it (transport.deliveryProcessed) → when the receiving session is gone (or replaced) while
|
|
9
|
+
// the entry is unprocessed, moved to the identity's ORPHANS → the daemon redelivers orphans through
|
|
10
|
+
// the normal route, or notifies the sender when that fails.
|
|
11
|
+
//
|
|
12
|
+
// Files under cfg.stateDir: `<identity>.inflight.jsonl` (append) and `<identity>.orphans.jsonl`.
|
|
13
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
export interface InflightEntry {
|
|
17
|
+
id: string
|
|
18
|
+
/** Epoch ms of the confirmed delivery. */
|
|
19
|
+
ts: number
|
|
20
|
+
/** Wire sentAt of the original envelope (shown on redelivery). */
|
|
21
|
+
sentAt: string
|
|
22
|
+
/** Receiving endpoint. */
|
|
23
|
+
identity: string
|
|
24
|
+
personality: string
|
|
25
|
+
runtime: string
|
|
26
|
+
cwd: string
|
|
27
|
+
/** Hosting session token at delivery (supervisor pid) — a different/absent token = that session is gone. */
|
|
28
|
+
sessionToken: string | null
|
|
29
|
+
caller: { personality: string; runtime: string }
|
|
30
|
+
message: string
|
|
31
|
+
topic?: string
|
|
32
|
+
attachments?: string[]
|
|
33
|
+
/** The presented envelope (what the model sees) — the processed-check needle source. */
|
|
34
|
+
rendered: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const inflightPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.inflight.jsonl`)
|
|
38
|
+
const orphansPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.orphans.jsonl`)
|
|
39
|
+
|
|
40
|
+
function readJsonl(path: string): InflightEntry[] {
|
|
41
|
+
if (!existsSync(path)) return []
|
|
42
|
+
const out: InflightEntry[] = []
|
|
43
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
44
|
+
if (!line.trim()) continue
|
|
45
|
+
try {
|
|
46
|
+
out.push(JSON.parse(line) as InflightEntry)
|
|
47
|
+
} catch {
|
|
48
|
+
/* a torn line is skipped, never fatal */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeJsonl(path: string, entries: InflightEntry[]): void {
|
|
55
|
+
if (entries.length === 0) {
|
|
56
|
+
rmSync(path, { force: true })
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const tmp = `${path}.${process.pid}.tmp`
|
|
60
|
+
writeFileSync(tmp, entries.map(e => JSON.stringify(e)).join('\n') + '\n', { mode: 0o600 })
|
|
61
|
+
renameSync(tmp, path)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function recordInflight(stateDir: string, entry: InflightEntry): void {
|
|
65
|
+
appendFileSync(inflightPath(stateDir, entry.identity), JSON.stringify(entry) + '\n', { mode: 0o600 })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function listInflightIdentities(stateDir: string): string[] {
|
|
69
|
+
let names: string[]
|
|
70
|
+
try {
|
|
71
|
+
names = readdirSync(stateDir)
|
|
72
|
+
} catch {
|
|
73
|
+
return []
|
|
74
|
+
}
|
|
75
|
+
return names.filter(n => n.endsWith('.inflight.jsonl')).map(n => n.slice(0, -'.inflight.jsonl'.length))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readInflight(stateDir: string, identity: string): InflightEntry[] {
|
|
79
|
+
return readJsonl(inflightPath(stateDir, identity))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface InflightSweepDeps {
|
|
83
|
+
/** Current hosting token of the identity's session (null = no live session). */
|
|
84
|
+
sessionToken: (identity: string) => string | null
|
|
85
|
+
/** Did the receiving session answer this entry? */
|
|
86
|
+
processed: (entry: InflightEntry) => boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One pass over an identity's ledger: drop processed entries; entries whose receiving session is
|
|
91
|
+
* gone or was replaced (token differs) and that are still unprocessed move to ORPHANS. Entries of the
|
|
92
|
+
* still-live session that are unprocessed stay. Returns the number orphaned.
|
|
93
|
+
*/
|
|
94
|
+
export function sweepInflight(stateDir: string, identity: string, deps: InflightSweepDeps): number {
|
|
95
|
+
const entries = readInflight(stateDir, identity)
|
|
96
|
+
if (entries.length === 0) return 0
|
|
97
|
+
const current = deps.sessionToken(identity)
|
|
98
|
+
const keep: InflightEntry[] = []
|
|
99
|
+
const orphaned: InflightEntry[] = []
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
let done = false
|
|
102
|
+
try {
|
|
103
|
+
done = deps.processed(e)
|
|
104
|
+
} catch {
|
|
105
|
+
done = false
|
|
106
|
+
}
|
|
107
|
+
if (done) continue
|
|
108
|
+
if (current !== null && current === e.sessionToken) keep.push(e)
|
|
109
|
+
else orphaned.push(e)
|
|
110
|
+
}
|
|
111
|
+
writeJsonl(inflightPath(stateDir, identity), keep)
|
|
112
|
+
if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
|
|
113
|
+
return orphaned.length
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function listOrphanIdentities(stateDir: string): string[] {
|
|
117
|
+
let names: string[]
|
|
118
|
+
try {
|
|
119
|
+
names = readdirSync(stateDir)
|
|
120
|
+
} catch {
|
|
121
|
+
return []
|
|
122
|
+
}
|
|
123
|
+
return names.filter(n => n.endsWith('.orphans.jsonl')).map(n => n.slice(0, -'.orphans.jsonl'.length))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Take (and remove) an identity's orphans — the caller owns redelivery-or-notify for each. */
|
|
127
|
+
export function takeOrphans(stateDir: string, identity: string): InflightEntry[] {
|
|
128
|
+
const path = orphansPath(stateDir, identity)
|
|
129
|
+
const entries = readJsonl(path)
|
|
130
|
+
rmSync(path, { force: true })
|
|
131
|
+
return entries
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Remove an identity's ledger + orphans (peer removal). */
|
|
135
|
+
export function purgeInflight(stateDir: string, identity: string): void {
|
|
136
|
+
rmSync(inflightPath(stateDir, identity), { force: true })
|
|
137
|
+
rmSync(orphansPath(stateDir, identity), { force: true })
|
|
138
|
+
}
|
package/src/opencode/config.ts
CHANGED
|
@@ -61,6 +61,43 @@ export function readOpencodeExtension(path: string): JsonObject {
|
|
|
61
61
|
return fragment
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** Is the peer's provider explicit (`model` = `<provider>/<model>`)? */
|
|
65
|
+
export function hasExplicitModel(config: JsonObject): boolean {
|
|
66
|
+
return typeof config.model === 'string' && /^[^/\s]+\/\S+$/.test(config.model)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Provider pinning (docs/20 §Provider pinning, OC04). Without an explicit model OpenCode falls back to
|
|
71
|
+
* its own hosted default (`opencode/big-pickle`) and the whole composed doctrine goes there. Owner
|
|
72
|
+
* policy (18.10): allowed, but NEVER silently — an implicit provider passes through unchanged and the
|
|
73
|
+
* launch records a warning (see the adapter) while the actual model is reported in the fleet snapshot.
|
|
74
|
+
* An EXPLICIT model is pinned: `small_model` defaults to `model` (titles never drift to another
|
|
75
|
+
* provider) and `enabled_providers` is exactly those providers; an explicit list that excludes them
|
|
76
|
+
* is a misconfiguration and refuses loudly.
|
|
77
|
+
*/
|
|
78
|
+
export function pinProvider(config: JsonObject, configPath: string): JsonObject {
|
|
79
|
+
const hint =
|
|
80
|
+
`configure the peer's provider in ${configPath} (or OPENCODE_CONFIG_CONTENT in the peer's launch.env): ` +
|
|
81
|
+
`"model": "<provider>/<model>" plus that provider's "provider" block; see docs/20 §Provider pinning`
|
|
82
|
+
const providerOf = (v: unknown, field: string): string => {
|
|
83
|
+
if (typeof v !== 'string' || !/^[^/\s]+\/\S+$/.test(v)) {
|
|
84
|
+
throw new Error(`opencode: no explicit ${field} — refusing to launch on OpenCode's implicit default provider. ${hint}`)
|
|
85
|
+
}
|
|
86
|
+
return v.slice(0, v.indexOf('/'))
|
|
87
|
+
}
|
|
88
|
+
if (!hasExplicitModel(config)) return config
|
|
89
|
+
const model = providerOf(config.model, 'model')
|
|
90
|
+
const smallModel = config.small_model === undefined ? config.model : config.small_model
|
|
91
|
+
const small = providerOf(smallModel, 'small_model')
|
|
92
|
+
const needed = [...new Set([model, small])]
|
|
93
|
+
let enabled = config.enabled_providers
|
|
94
|
+
if (enabled === undefined) enabled = needed
|
|
95
|
+
else if (!Array.isArray(enabled) || !needed.every(p => (enabled as unknown[]).includes(p))) {
|
|
96
|
+
throw new Error(`opencode: enabled_providers ${JSON.stringify(enabled)} must include ${JSON.stringify(needed)} (the model providers). ${hint}`)
|
|
97
|
+
}
|
|
98
|
+
return { ...config, small_model: smallModel, enabled_providers: enabled }
|
|
99
|
+
}
|
|
100
|
+
|
|
64
101
|
export interface ComposeOpencodeConfigInput {
|
|
65
102
|
personality: string
|
|
66
103
|
doctrine: string
|
|
@@ -90,6 +127,7 @@ export function composeOpencodeConfig(input: ComposeOpencodeConfigInput, options
|
|
|
90
127
|
const path = join(paths.dir, `${stem}.json`)
|
|
91
128
|
if (existsSync(path)) result = mergeOpencodeConfig(result, readOpencodeExtension(path))
|
|
92
129
|
}
|
|
130
|
+
result = pinProvider(result, paths.config)
|
|
93
131
|
// Last writer for required fields; foreign MCP servers, agents, plugins and
|
|
94
132
|
// provider settings survive. Effective managed overrides still need a live gate.
|
|
95
133
|
return mergeOpencodeConfig(result, {
|
|
@@ -151,6 +151,7 @@ async function server(input) {
|
|
|
151
151
|
function noteModel(info) {
|
|
152
152
|
if (info && info.role === 'assistant' && typeof info.providerID === 'string' && typeof info.modelID === 'string') {
|
|
153
153
|
lastModel = { providerID: info.providerID, modelID: info.modelID }
|
|
154
|
+
state.model = info.providerID + '/' + info.modelID // the ACTUAL model, for operators (fleet snapshot)
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
|
package/src/opencode/journal.ts
CHANGED
package/src/supervisor/daemon.ts
CHANGED
|
@@ -113,6 +113,10 @@ const SIGWINCH_REDRAW_WAIT_MS = 300
|
|
|
113
113
|
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
114
114
|
interface PtyChild {
|
|
115
115
|
pid: number
|
|
116
|
+
/** Resolves with the exit status once the process is REAPED (the pty exit callback fires earlier). */
|
|
117
|
+
exited?: Promise<number>
|
|
118
|
+
exitCode?: number | null
|
|
119
|
+
signalCode?: string | number | null
|
|
116
120
|
terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
|
|
117
121
|
kill(sig?: number | string): void
|
|
118
122
|
}
|
|
@@ -385,26 +389,33 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
385
389
|
}
|
|
386
390
|
}
|
|
387
391
|
},
|
|
388
|
-
exit(
|
|
392
|
+
exit() {
|
|
389
393
|
// resilience-b exit-cause (spawn-flip Ф0b): record WHY the hosted child died into exits.log,
|
|
390
394
|
// mirroring the tmux pane-died hook (ev=session-exit dead_status/dead_signal), so the daemon's
|
|
391
395
|
// supervise-tick death classification + crash-loop accounting have the cause. host=supervisor
|
|
392
|
-
// distinguishes the source.
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
396
|
+
// distinguishes the source.
|
|
397
|
+
//
|
|
398
|
+
// The Bun-native pty `exit` callback fires on pty EOF, BEFORE the process is reaped, and passes
|
|
399
|
+
// (0, null) even for a SIGKILL (measured, Bun 1.3.13: callback (0, null); `exited` → 137,
|
|
400
|
+
// signalCode 'SIGKILL'). Reporting those args recorded a SIGKILL as a clean exit 0 (linus OC07).
|
|
401
|
+
// So wait for the reap (bounded) and read the subprocess's own exitCode/signalCode.
|
|
402
|
+
exitLogged = true // this IS the death record — a racing shutdown must not write a second one
|
|
403
|
+
const finish = (): void => {
|
|
404
|
+
const status = child.signalCode != null ? undefined : (child.exitCode ?? undefined)
|
|
405
|
+
const signal = child.signalCode ?? undefined
|
|
406
|
+
recordStructuralStop('child-exit', status, signal)
|
|
407
|
+
appendExitEvent({
|
|
408
|
+
ev: 'session-exit',
|
|
409
|
+
identity: session,
|
|
410
|
+
dead_status: status ?? '',
|
|
411
|
+
dead_signal: signal ?? '',
|
|
412
|
+
cause: 'child-exit',
|
|
413
|
+
host: 'supervisor',
|
|
414
|
+
})
|
|
415
|
+
shutdown()
|
|
416
|
+
}
|
|
417
|
+
const reaped = child.exited ?? Promise.resolve(0)
|
|
418
|
+
void Promise.race([reaped.catch(() => 0), new Promise(r => setTimeout(r, 2000))]).then(finish)
|
|
408
419
|
},
|
|
409
420
|
},
|
|
410
421
|
} as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
|
package/src/transport/index.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
type ProcessAddress,
|
|
34
34
|
} from '../core/socket.ts'
|
|
35
35
|
import { buildEnvelope, formatSentAt, renderEnvelopeForAgent } from '../codec/index.ts'
|
|
36
|
-
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
36
|
+
import { endpointPrivateDir, resolveGlobalRoot } from '../storage/index.ts'
|
|
37
37
|
import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
|
|
38
38
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
39
39
|
// Delivery markers are OWNED by the runtime adapter. transport
|
|
@@ -805,6 +805,108 @@ export function transcriptCarriesEnvelope(
|
|
|
805
805
|
return false
|
|
806
806
|
}
|
|
807
807
|
|
|
808
|
+
/** The shape transport needs to judge one inflight delivery (a subset of lifecycle's InflightEntry —
|
|
809
|
+
* transport never imports lifecycle). */
|
|
810
|
+
export interface ProcessedProbe {
|
|
811
|
+
runtime: string
|
|
812
|
+
personality: string
|
|
813
|
+
cwd: string
|
|
814
|
+
ts: number
|
|
815
|
+
rendered: string
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const PROCESSED_TAIL_BYTES = 16 * 1024 * 1024
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Did the receiving session ANSWER this delivery (OC07)? «Recorded» is not «processed»: measured
|
|
822
|
+
* 18.10 on claude, a message SIGKILLed mid-request had its `user` record (and the API request blob)
|
|
823
|
+
* in the transcript but no assistant record after it — the model never answered, and a fresh next
|
|
824
|
+
* session would not see it. So the proof is a model response AFTER the record that carries the
|
|
825
|
+
* envelope:
|
|
826
|
+
* claude — a `type:"assistant"` record after a non-queue-operation record carrying the needles;
|
|
827
|
+
* codex — a non-user `response_item` (assistant message / function_call / reasoning) or an
|
|
828
|
+
* `agent_message` event after the user input carrying the needles;
|
|
829
|
+
* opencode — an assistant `message.updated` whose parentID is the user message whose text part
|
|
830
|
+
* carries the needles (journal; docs/20).
|
|
831
|
+
* Reads bounded tails only. False → the entry stays inflight (or orphans if its session is gone):
|
|
832
|
+
* a false negative costs a duplicate redelivery, never a silent loss.
|
|
833
|
+
*/
|
|
834
|
+
export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
835
|
+
const needles = confirmNeedles(probe.rendered)
|
|
836
|
+
if (!needles.length || needles.every(n => !n)) return false
|
|
837
|
+
if (getAdapter(probe.runtime).deliveryConfirm === 'structural') {
|
|
838
|
+
const dir = endpointPrivateDir(probe.runtime, probe.personality, { env })
|
|
839
|
+
if (!dir) return false
|
|
840
|
+
const path = join(dir, 'events.jsonl')
|
|
841
|
+
let size = 0
|
|
842
|
+
try {
|
|
843
|
+
size = statSync(path).size
|
|
844
|
+
} catch {
|
|
845
|
+
return false
|
|
846
|
+
}
|
|
847
|
+
const userIDs = new Set<string>()
|
|
848
|
+
for (const line of readFileTailFrom(path, Math.max(0, size - PROCESSED_TAIL_BYTES)).split('\n')) {
|
|
849
|
+
if (!line.trim()) continue
|
|
850
|
+
let e: { type?: string; properties?: { part?: { type?: string; text?: string; messageID?: string }; info?: { role?: string; parentID?: string } } }
|
|
851
|
+
try {
|
|
852
|
+
e = JSON.parse(line)
|
|
853
|
+
} catch {
|
|
854
|
+
continue
|
|
855
|
+
}
|
|
856
|
+
const part = e.properties?.part
|
|
857
|
+
if (e.type === 'message.part.updated' && part?.type === 'text' && typeof part.text === 'string' && part.messageID) {
|
|
858
|
+
const text = part.text.replace(/\r/g, '')
|
|
859
|
+
if (needles.every(n => text.includes(n))) userIDs.add(part.messageID)
|
|
860
|
+
}
|
|
861
|
+
const info = e.properties?.info
|
|
862
|
+
if (e.type === 'message.updated' && info?.role === 'assistant' && info.parentID && userIDs.has(info.parentID)) return true
|
|
863
|
+
}
|
|
864
|
+
return false
|
|
865
|
+
}
|
|
866
|
+
for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
|
|
867
|
+
let st
|
|
868
|
+
try {
|
|
869
|
+
st = statSync(path)
|
|
870
|
+
} catch {
|
|
871
|
+
continue
|
|
872
|
+
}
|
|
873
|
+
if (st.mtimeMs < probe.ts - 5000) continue
|
|
874
|
+
let carried = false
|
|
875
|
+
for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
|
|
876
|
+
if (!line.trim()) continue
|
|
877
|
+
let o: { type?: string; payload?: { type?: string; role?: string } }
|
|
878
|
+
try {
|
|
879
|
+
o = JSON.parse(line)
|
|
880
|
+
} catch {
|
|
881
|
+
continue
|
|
882
|
+
}
|
|
883
|
+
if (!carried) {
|
|
884
|
+
if (o.type !== 'queue-operation' && jsonStringValuesContainAll(o, needles)) carried = true
|
|
885
|
+
continue
|
|
886
|
+
}
|
|
887
|
+
if (o.type === 'assistant') return true // claude
|
|
888
|
+
if (o.type === 'response_item' && o.payload && o.payload.role !== 'user') return true // codex
|
|
889
|
+
if (o.type === 'event_msg' && o.payload?.type === 'agent_message') return true // codex
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return false
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** One confirmed delivery to an AGENT endpoint, as transport reports it to the inflight ledger. */
|
|
896
|
+
export interface InflightNote {
|
|
897
|
+
sentAt: string
|
|
898
|
+
identity: string
|
|
899
|
+
personality: string
|
|
900
|
+
runtime: string
|
|
901
|
+
cwd: string
|
|
902
|
+
sessionToken: string | null
|
|
903
|
+
caller: { personality: string; runtime: string }
|
|
904
|
+
message: string
|
|
905
|
+
topic?: string
|
|
906
|
+
attachments?: string[]
|
|
907
|
+
rendered: string
|
|
908
|
+
}
|
|
909
|
+
|
|
808
910
|
function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
809
911
|
const raw = env?.IAPEER_COMPACT_DONE_TIMEOUT_MS
|
|
810
912
|
const n = raw === undefined ? NaN : Number(raw)
|
|
@@ -1084,6 +1186,8 @@ export interface ComposerQueuedDelivery {
|
|
|
1084
1186
|
topic?: string
|
|
1085
1187
|
enqueuedAtMs: number
|
|
1086
1188
|
sessionToken: string
|
|
1189
|
+
/** OC07 — what the inflight ledger needs once the queued delivery lands. */
|
|
1190
|
+
inflight?: InflightInfo
|
|
1087
1191
|
}
|
|
1088
1192
|
|
|
1089
1193
|
export interface ComposerQueueTryEnqueueArgs {
|
|
@@ -1094,6 +1198,7 @@ export interface ComposerQueueTryEnqueueArgs {
|
|
|
1094
1198
|
topic?: string
|
|
1095
1199
|
/** Router-acceptance instant (formatSentAt) — the queued ACK's `ts` mirrors it. */
|
|
1096
1200
|
sentAt?: string
|
|
1201
|
+
inflight?: InflightInfo
|
|
1097
1202
|
}
|
|
1098
1203
|
|
|
1099
1204
|
export interface ComposerQueueRouteDeps {
|
|
@@ -1137,6 +1242,10 @@ export interface RouteDeps {
|
|
|
1137
1242
|
* and cannot reap the session before the just-delivered message's turn record lands. Injected (same
|
|
1138
1243
|
* layering as noteLiveTopic — lifecycle.setLastDelivered wired in main.ts). */
|
|
1139
1244
|
noteDelivered?: (identity: string) => void
|
|
1245
|
+
/** OC07 — record a CONFIRMED delivery to an AGENT endpoint in the inflight ledger, so a session that
|
|
1246
|
+
* dies before answering it does not take the message along silently (lifecycle.recordInflight wired
|
|
1247
|
+
* in main.ts; same layering as noteDelivered). Best-effort: a hook error never fails the delivery. */
|
|
1248
|
+
noteInflight?: (note: InflightNote) => void
|
|
1140
1249
|
/** H4 launchd-managed detector (injected so transport never imports lifecycle —
|
|
1141
1250
|
* main.ts wires the real `isLaunchdManaged`). A MISS on a launchd-managed target is
|
|
1142
1251
|
* almost always a TRANSIENT restart window (router restart on connect / `iapeer
|
|
@@ -1165,6 +1274,37 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
|
|
|
1165
1274
|
}
|
|
1166
1275
|
}
|
|
1167
1276
|
|
|
1277
|
+
type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string }
|
|
1278
|
+
|
|
1279
|
+
/** Best-effort inflight record (OC07) — only for agent (tui) endpoints. */
|
|
1280
|
+
function noteInflight(
|
|
1281
|
+
deps: Pick<RouteDeps, 'noteInflight'>,
|
|
1282
|
+
target: DeliveryTarget,
|
|
1283
|
+
cwd: string,
|
|
1284
|
+
info: InflightInfo,
|
|
1285
|
+
env: NodeJS.ProcessEnv,
|
|
1286
|
+
): void {
|
|
1287
|
+
if (!deps.noteInflight) return
|
|
1288
|
+
try {
|
|
1289
|
+
if (getAdapter(target.runtime).kind !== 'tui') return
|
|
1290
|
+
deps.noteInflight({
|
|
1291
|
+
sentAt: info.sentAt,
|
|
1292
|
+
identity: target.address,
|
|
1293
|
+
personality: target.personality,
|
|
1294
|
+
runtime: target.runtime,
|
|
1295
|
+
cwd,
|
|
1296
|
+
sessionToken: hostSessionToken(target.address, env),
|
|
1297
|
+
caller: { personality: info.caller.personality, runtime: info.caller.runtime },
|
|
1298
|
+
message: info.message,
|
|
1299
|
+
...(info.topic ? { topic: info.topic } : {}),
|
|
1300
|
+
...(info.attachments?.length ? { attachments: info.attachments } : {}),
|
|
1301
|
+
rendered: renderEnvelopeForAgent(info.envelope),
|
|
1302
|
+
})
|
|
1303
|
+
} catch {
|
|
1304
|
+
/* a post-delivery hook must never fail the delivery */
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1168
1308
|
/** Best-effort lastDelivered stamp (В7) — records a CONFIRMED live delivery so the idle-reap proxy
|
|
1169
1309
|
* cannot reap the session out from under a just-delivered message before its turn record lands. */
|
|
1170
1310
|
function noteDelivered(deps: RouteDeps, identity: string): void {
|
|
@@ -1202,6 +1342,8 @@ export interface ComposerDeliveryQueueOptions {
|
|
|
1202
1342
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
1203
1343
|
/** В7 — stamp a confirmed queued delivery (same seam as RouteDeps.noteDelivered). */
|
|
1204
1344
|
noteDelivered?: (identity: string) => void
|
|
1345
|
+
/** OC07 — same seam as RouteDeps.noteInflight, for a queued delivery once it lands. */
|
|
1346
|
+
noteInflight?: (note: InflightNote) => void
|
|
1205
1347
|
onDelivered?: (caller: ResolvedCaller) => void
|
|
1206
1348
|
}
|
|
1207
1349
|
|
|
@@ -1349,6 +1491,7 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1349
1491
|
} catch {
|
|
1350
1492
|
/* best-effort */
|
|
1351
1493
|
}
|
|
1494
|
+
if (job.inflight) noteInflight({ noteInflight: opts.noteInflight }, job.target, job.peer.cwd, job.inflight, opts.env ?? process.env)
|
|
1352
1495
|
try {
|
|
1353
1496
|
opts.onDelivered?.(job.caller)
|
|
1354
1497
|
} catch {
|
|
@@ -1377,6 +1520,7 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1377
1520
|
topic: args.topic,
|
|
1378
1521
|
enqueuedAtMs: monotonicMs(),
|
|
1379
1522
|
sessionToken: token,
|
|
1523
|
+
...(args.inflight ? { inflight: args.inflight } : {}),
|
|
1380
1524
|
}
|
|
1381
1525
|
const q = queues.get(args.target.address) ?? []
|
|
1382
1526
|
q.push(job)
|
|
@@ -1601,6 +1745,8 @@ async function routeSendInner(
|
|
|
1601
1745
|
attachments: spooled.value.paths,
|
|
1602
1746
|
message,
|
|
1603
1747
|
})
|
|
1748
|
+
// OC07 — what the inflight ledger needs to redeliver this message if the receiving session dies first.
|
|
1749
|
+
const inflightInfo = { sentAt, caller, message, topic, attachments: spooled.value.paths, envelope }
|
|
1604
1750
|
|
|
1605
1751
|
// M3 wake_policy:"ephemeral" — an ephemeral target is NEVER injected into a live
|
|
1606
1752
|
// session and never woken synchronously here: the delivery is ALWAYS enqueued
|
|
@@ -1620,7 +1766,7 @@ async function routeSendInner(
|
|
|
1620
1766
|
// target must still obey the domain guard even though selection is exact).
|
|
1621
1767
|
const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
|
|
1622
1768
|
if (!liveGuard.ok) return liveGuard
|
|
1623
|
-
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt })
|
|
1769
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt, inflight: inflightInfo })
|
|
1624
1770
|
if (queued) return queued
|
|
1625
1771
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
1626
1772
|
// deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
|
|
@@ -1629,6 +1775,7 @@ async function routeSendInner(
|
|
|
1629
1775
|
if (!delivered.ok) return delivered
|
|
1630
1776
|
noteTopic(deps, target.value.address, topic)
|
|
1631
1777
|
noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
|
|
1778
|
+
noteInflight(deps, target.value, peer.cwd, inflightInfo, env)
|
|
1632
1779
|
return ok({
|
|
1633
1780
|
ok: true,
|
|
1634
1781
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
@@ -1661,6 +1808,7 @@ async function routeSendInner(
|
|
|
1661
1808
|
const delivered = await deliverWarm(revived.value, envelope, peer.cwd, {}, env)
|
|
1662
1809
|
if (delivered.ok) {
|
|
1663
1810
|
noteTopic(deps, revived.value.address, topic)
|
|
1811
|
+
noteInflight(deps, revived.value, peer.cwd, inflightInfo, env)
|
|
1664
1812
|
return ok({
|
|
1665
1813
|
ok: true,
|
|
1666
1814
|
delivered_to: { personality: revived.value.personality, runtime: revived.value.runtime },
|
|
@@ -1701,11 +1849,12 @@ async function routeSendInner(
|
|
|
1701
1849
|
// a false ok:true — the class the contract forbids. A delivery failure here is
|
|
1702
1850
|
// surfaced loudly (false-FAIL over false-OK).
|
|
1703
1851
|
if (woke.taskDelivered === false) {
|
|
1704
|
-
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic, sentAt })
|
|
1852
|
+
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic, sentAt, inflight: inflightInfo })
|
|
1705
1853
|
if (queued) return queued
|
|
1706
1854
|
const delivered = await deliverWarm(live.value, envelope, peer.cwd, {}, env)
|
|
1707
1855
|
if (!delivered.ok) return delivered
|
|
1708
1856
|
noteTopic(deps, live.value.address, topic)
|
|
1857
|
+
noteInflight(deps, live.value, peer.cwd, inflightInfo, env)
|
|
1709
1858
|
return ok({
|
|
1710
1859
|
ok: true,
|
|
1711
1860
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|
|
@@ -1714,6 +1863,7 @@ async function routeSendInner(
|
|
|
1714
1863
|
})
|
|
1715
1864
|
}
|
|
1716
1865
|
// The envelope was delivered as the boot first-message during wake.
|
|
1866
|
+
noteInflight(deps, live.value, peer.cwd, inflightInfo, env)
|
|
1717
1867
|
return ok({
|
|
1718
1868
|
ok: true,
|
|
1719
1869
|
delivered_to: { personality: live.value.personality, runtime: live.value.runtime },
|