@agfpd/iapeer 0.4.121 → 0.4.123
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 +26 -0
- package/package.json +1 -1
- package/src/daemon/fleet.ts +27 -4
- package/src/daemon/main.ts +36 -14
- package/src/launch/adapters/opencode.ts +3 -1
- package/src/launch/index.ts +1 -0
- package/src/launch/types.ts +1 -1
- package/src/lifecycle/index.ts +35 -8
- package/src/lifecycle/inflight.ts +50 -6
- package/src/opencode/iapeer-plugin.mjs +67 -2
- package/src/opencode/journal.ts +2 -0
- package/src/transport/index.ts +70 -0
package/docs/15-fleet-api.md
CHANGED
|
@@ -81,6 +81,7 @@ Field notes:
|
|
|
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
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.
|
|
84
|
+
- `runtimes[].warnings` — the endpoint's current-session launch self-check findings (today `opencode`: a missing or failed plugin, an MCP server not connected; docs/20 §Launch self-check). Absent when clean. Additive.
|
|
84
85
|
- `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).
|
|
85
86
|
- `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
|
|
86
87
|
- `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,23 @@ 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
|
+
## Launch self-check (OC04)
|
|
127
|
+
|
|
128
|
+
OpenCode tolerates some broken surfaces silently: a fragment plugin whose file
|
|
129
|
+
is missing or that throws on load, and an MCP server that does not connect
|
|
130
|
+
(e.g. an unreachable memory MCP), all still give a «working» session with the
|
|
131
|
+
surface absent. Before it reports readiness, the core plugin checks:
|
|
132
|
+
- every MCP server's native status (`connected`/`disabled` are fine);
|
|
133
|
+
- every `file://` plugin in the effective config exists;
|
|
134
|
+
- the native log (`<XDG_DATA_HOME>/opencode/log/opencode.log`, lines of THIS
|
|
135
|
+
process found through a `iapeer-selfcheck <launchID>` marker line) for
|
|
136
|
+
`failed to load plugin`.
|
|
137
|
+
Findings are never a refusal (the session is usable) and never silent:
|
|
138
|
+
`runtime.ready.properties.warnings` (additive), `state.json` `warnings`,
|
|
139
|
+
`ev=launch-warning` in lifecycle.log per warning, and the fleet field
|
|
140
|
+
`runtimes[].warnings` (docs/15). Native run of 2026-10-18: unreachable MCP,
|
|
141
|
+
missing plugin and throwing plugin each reported.
|
|
142
|
+
|
|
126
143
|
## Provider pinning
|
|
127
144
|
|
|
128
145
|
Without an explicit `model`, native OpenCode uses its own hosted default
|
|
@@ -175,6 +192,15 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
|
|
|
175
192
|
(lifecycle.log) and `ev=inflight-redelivered|inflight-undelivered`
|
|
176
193
|
(delivery.log).
|
|
177
194
|
- A false «not processed» costs a visible duplicate, never a silent loss.
|
|
195
|
+
- Interrupted turns. «Processed» (a model response began) is not «finished»: a
|
|
196
|
+
turn killed mid-tool may already have had side effects, so it is never
|
|
197
|
+
redelivered. The ledger keeps an entry until its turn finishes (claude: an
|
|
198
|
+
assistant record with a terminal `stop_reason`; codex: `task_complete`;
|
|
199
|
+
opencode: a completed assistant message for it with a finish other than
|
|
200
|
+
`tool-calls`); a finished later message covers earlier ones. If the session
|
|
201
|
+
dies with the turn unfinished, the sender gets an explicit notice from iapeer
|
|
202
|
+
(«the turn was INTERRUPTED and its result is not guaranteed»), logged as
|
|
203
|
+
`ev=inflight-interrupted`.
|
|
178
204
|
- Unknown is not failed. When a live receiver's acceptance record does not appear
|
|
179
205
|
within the confirm window (a busy claude records a queued message late), the
|
|
180
206
|
send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
|
package/package.json
CHANGED
package/src/daemon/fleet.ts
CHANGED
|
@@ -94,6 +94,9 @@ export interface FleetRuntimeStatus {
|
|
|
94
94
|
/** The model the runtime ACTUALLY used last (`provider/model`, from the endpoint's own state; docs/20
|
|
95
95
|
* §Provider pinning). Absent until a first assistant turn, or for runtimes that do not report it. */
|
|
96
96
|
model?: string
|
|
97
|
+
/** Launch self-check warnings of the endpoint's current session (docs/20 OC04): a missing or failed
|
|
98
|
+
* plugin, an MCP server not connected. Absent when clean. */
|
|
99
|
+
warnings?: string[]
|
|
97
100
|
/** Present (true) iff a HUMAN operator is attached to this live hosted session. */
|
|
98
101
|
attached?: boolean
|
|
99
102
|
/** Whether this runtime is IN A TURN right now — a SEPARATE concept from `status`, which
|
|
@@ -245,11 +248,17 @@ export function buildFleetSnapshot(
|
|
|
245
248
|
* never emitted, which is exactly its "no claim" semantics. */
|
|
246
249
|
activityOf?: (personality: string, runtime: string) => TurnActivity | undefined,
|
|
247
250
|
): FleetSnapshot {
|
|
248
|
-
const privateDirOf = (runtime: string, personality: string): { private_dir?: string; model?: string } => {
|
|
251
|
+
const privateDirOf = (runtime: string, personality: string): { private_dir?: string; model?: string; warnings?: string[] } => {
|
|
249
252
|
const d = endpointPrivateDir(runtime, personality, { env })
|
|
250
253
|
if (!d) return {}
|
|
251
|
-
const
|
|
252
|
-
|
|
254
|
+
const st = readOpencodeState(join(d, 'state.json'))
|
|
255
|
+
const model = st?.model
|
|
256
|
+
const warnings = Array.isArray(st?.warnings) ? st!.warnings!.filter(w => typeof w === 'string') : []
|
|
257
|
+
return {
|
|
258
|
+
private_dir: d,
|
|
259
|
+
...(typeof model === 'string' && model ? { model } : {}),
|
|
260
|
+
...(warnings.length ? { warnings } : {}),
|
|
261
|
+
}
|
|
253
262
|
}
|
|
254
263
|
const peers: FleetPeer[] = ops.listPeers({ env }).map(r => {
|
|
255
264
|
const runtimes: FleetRuntimeStatus[] = r.runtimes.map(s => {
|
|
@@ -608,7 +617,7 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
608
617
|
// observations in; the snapshot below reads it. Reading is O(1): no file touched per request.
|
|
609
618
|
const activity = opts.activity ?? new ActivityBoard()
|
|
610
619
|
const activityOf = (personality: string, runtime: string): TurnActivity | undefined =>
|
|
611
|
-
activity.get(personality, runtime, Date.now())
|
|
620
|
+
structuralActivity(personality, runtime, env) ?? activity.get(personality, runtime, Date.now())
|
|
612
621
|
let opsPromise: Promise<FleetOps> | undefined
|
|
613
622
|
const getOps = (): Promise<FleetOps> => {
|
|
614
623
|
if (opts.ops) return Promise.resolve(opts.ops)
|
|
@@ -891,6 +900,20 @@ async function runCommand(
|
|
|
891
900
|
}
|
|
892
901
|
}
|
|
893
902
|
|
|
903
|
+
/**
|
|
904
|
+
* Activity of a STRUCTURAL runtime straight from its own state (docs/20: state.json `status` is the
|
|
905
|
+
* busy/idle source, not a transcript watch). busy/approval → working, idle → idle; anything else (or
|
|
906
|
+
* no private dir) → undefined, so the transcript turn-watch decides for claude/codex as before.
|
|
907
|
+
*/
|
|
908
|
+
export function structuralActivity(personality: string, runtime: string, env: NodeJS.ProcessEnv): TurnActivity | undefined {
|
|
909
|
+
const dir = endpointPrivateDir(runtime, personality, { env })
|
|
910
|
+
if (!dir) return undefined
|
|
911
|
+
const status = readOpencodeState(join(dir, 'state.json'))?.status
|
|
912
|
+
if (status === 'busy' || status === 'approval') return 'working'
|
|
913
|
+
if (status === 'idle') return 'idle'
|
|
914
|
+
return undefined
|
|
915
|
+
}
|
|
916
|
+
|
|
894
917
|
/** Serve the SSE event stream: optional replay of the newest N historical events,
|
|
895
918
|
* then live tail. At-least-once semantics (a reconnect may re-see events — `id`
|
|
896
919
|
* is the event's epoch-ms for client-side dedup); heartbeat comment every 15 s. */
|
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
|
+
deliveryCompleted,
|
|
59
60
|
deliveryLanded,
|
|
60
61
|
deliveryProcessed,
|
|
61
62
|
routeSend,
|
|
@@ -78,7 +79,7 @@ import { startGoalWatch } from './goalwatch.ts'
|
|
|
78
79
|
import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
|
|
79
80
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
80
81
|
import { randomBytes } from 'crypto'
|
|
81
|
-
import { listInflightIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
|
|
82
|
+
import { listInflightIdentities, listInterruptedIdentities, listOrphanIdentities, recordInflight, sweepInflight, takeInterrupted, takeOrphans, type InflightEntry } from '../lifecycle/inflight.ts'
|
|
82
83
|
import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
|
|
83
84
|
|
|
84
85
|
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
@@ -272,6 +273,16 @@ export function redeliveryMessage(e: InflightEntry): string {
|
|
|
272
273
|
return `${REDELIVERY_PREFIX} ${why}; originally sent ${e.sentAt}]\n${e.message}`
|
|
273
274
|
}
|
|
274
275
|
|
|
276
|
+
/** Body of the interrupted-turn notice (OC07 extension): processed, but the turn died mid-work. */
|
|
277
|
+
export function interruptedNotice(e: InflightEntry): string {
|
|
278
|
+
return (
|
|
279
|
+
`iapeer: your message to "${e.personality}" (${e.runtime}), sent ${e.sentAt}${e.topic ? ` topic=${e.topic}` : ''}, ` +
|
|
280
|
+
`was being worked on when the receiving session died — the turn was INTERRUPTED and its result is not guaranteed ` +
|
|
281
|
+
`(it is NOT redelivered: side effects may already have happened). Check its state and resend if needed. ` +
|
|
282
|
+
`First line: ${e.message.split('\n')[0]!.slice(0, 200)}`
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
275
286
|
/** Body of the explicit non-delivery notice to the original sender. */
|
|
276
287
|
export function undeliveredNotice(e: InflightEntry, reason: string): string {
|
|
277
288
|
return (
|
|
@@ -292,18 +303,40 @@ export async function processInflight(
|
|
|
292
303
|
cfg: LifecycleConfig,
|
|
293
304
|
env: NodeJS.ProcessEnv,
|
|
294
305
|
routeDeps: RouteDeps,
|
|
295
|
-
seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean } = {},
|
|
306
|
+
seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean; completed?: (e: InflightEntry) => boolean } = {},
|
|
296
307
|
): Promise<void> {
|
|
297
308
|
const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
|
|
298
309
|
const send = seam.send ?? routeSend
|
|
310
|
+
/** A notice FROM iapeer to the entry's original sender (skipped when the sender is iapeer itself). */
|
|
311
|
+
const notifySender = async (e: InflightEntry, message: string): Promise<string> => {
|
|
312
|
+
try {
|
|
313
|
+
const index = readPeersIndex({ env })
|
|
314
|
+
const self = findPeer(index, 'iapeer')
|
|
315
|
+
if (!self || `${e.caller.runtime}-${e.caller.personality}` === `${self.runtime}-iapeer`) return 'skipped'
|
|
316
|
+
const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
|
|
317
|
+
const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message, topic: e.topic ?? 'delivery-failed' }, routeDeps)
|
|
318
|
+
return String(n.ok)
|
|
319
|
+
} catch {
|
|
320
|
+
return 'false'
|
|
321
|
+
}
|
|
322
|
+
}
|
|
299
323
|
for (const identity of listInflightIdentities(cfg.stateDir)) {
|
|
300
324
|
const n = sweepInflight(cfg.stateDir, identity, {
|
|
301
325
|
sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
|
|
302
326
|
processed,
|
|
327
|
+
completed: seam.completed ?? ((e: InflightEntry) => deliveryCompleted(e, env)),
|
|
303
328
|
landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
|
|
304
329
|
})
|
|
305
330
|
if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
|
|
306
331
|
}
|
|
332
|
+
// A turn the session STARTED on a message but did not finish before dying: never redelivered (its side
|
|
333
|
+
// effects may already have happened) — the sender is told explicitly that the result is not guaranteed.
|
|
334
|
+
for (const identity of listInterruptedIdentities(cfg.stateDir)) {
|
|
335
|
+
for (const e of takeInterrupted(cfg.stateDir, identity)) {
|
|
336
|
+
const notified = await notifySender(e, interruptedNotice(e))
|
|
337
|
+
appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-interrupted', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'false', notified })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
307
340
|
for (const identity of listOrphanIdentities(cfg.stateDir)) {
|
|
308
341
|
for (const e of takeOrphans(cfg.stateDir, identity)) {
|
|
309
342
|
const index = readPeersIndex({ env })
|
|
@@ -327,18 +360,7 @@ export async function processInflight(
|
|
|
327
360
|
reason = x instanceof Error ? x.message : String(x)
|
|
328
361
|
}
|
|
329
362
|
// Redelivery failed → the sender must learn it explicitly.
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
const self = findPeer(index, 'iapeer')
|
|
333
|
-
const callerAddr = `${e.caller.runtime}-${e.caller.personality}`
|
|
334
|
-
if (self && callerAddr !== `${self.runtime}-iapeer`) {
|
|
335
|
-
const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
|
|
336
|
-
const n = await send(sender, { personality: e.caller.personality, runtime: e.caller.runtime, message: undeliveredNotice(e, reason), topic: e.topic ?? 'delivery-failed' }, routeDeps)
|
|
337
|
-
notified = String(n.ok)
|
|
338
|
-
}
|
|
339
|
-
} catch {
|
|
340
|
-
notified = 'false'
|
|
341
|
-
}
|
|
363
|
+
const notified = await notifySender(e, undeliveredNotice(e, reason))
|
|
342
364
|
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 })
|
|
343
365
|
}
|
|
344
366
|
}
|
|
@@ -125,7 +125,9 @@ export const opencodeAdapter: RuntimeAdapter = {
|
|
|
125
125
|
const ping = await opencodeControl(controlSocket(spec.personality, env), { op: 'ping' }, 5000)
|
|
126
126
|
if (!ping.ok) return { ok: false, reason: `runtime.ready seen but control ping failed: ${ping.error}` }
|
|
127
127
|
if (ping.launchID !== launchID) return { ok: false, reason: `control socket answers launch ${ping.launchID}, expected ${launchID}` }
|
|
128
|
-
|
|
128
|
+
const w = (l.properties as { warnings?: unknown } | null)?.warnings
|
|
129
|
+
const warnings = Array.isArray(w) ? w.filter((x): x is string => typeof x === 'string') : []
|
|
130
|
+
return warnings.length ? { ok: true, warnings } : { ok: true }
|
|
129
131
|
}
|
|
130
132
|
if (l.type === 'runtime.error') {
|
|
131
133
|
const e = (l.properties as { error?: unknown } | null)?.error
|
package/src/launch/index.ts
CHANGED
|
@@ -241,6 +241,7 @@ export const launch: LaunchFn = async (
|
|
|
241
241
|
if (adapter.structuralReady && readyToken) {
|
|
242
242
|
const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
|
|
243
243
|
if (!r.ok) return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
|
|
244
|
+
for (const w of r.warnings ?? []) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
|
|
244
245
|
if (firstMessage.trim().length === 0) return ready(identity)
|
|
245
246
|
if (!adapter.deliver) return fail(identity, 'structural runtime has no deliver hook')
|
|
246
247
|
const d = await adapter.deliver({ personality: spec.personality, identity }, firstMessage, env)
|
package/src/launch/types.ts
CHANGED
|
@@ -428,7 +428,7 @@ export interface RuntimeAdapter {
|
|
|
428
428
|
|
|
429
429
|
/** Structural readiness replacing the pane-glyph gate: resolves when the runtime itself reported
|
|
430
430
|
* readiness for `readyToken` (fail on deadline / reported error / `alive()` false). */
|
|
431
|
-
structuralReady?(spec: LaunchSpec, readyToken: string, deadlineMs: number, alive: () => boolean, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; reason?: string }>
|
|
431
|
+
structuralReady?(spec: LaunchSpec, readyToken: string, deadlineMs: number, alive: () => boolean, env: NodeJS.ProcessEnv): Promise<{ ok: boolean; reason?: string; warnings?: string[] }>
|
|
432
432
|
|
|
433
433
|
/** Structural delivery (deliveryConfirm 'structural'): resolves ok only when THIS message is accepted
|
|
434
434
|
* and persisted by the runtime. */
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -648,12 +648,17 @@ export function resolveWakeMode(
|
|
|
648
648
|
argsResume: boolean | undefined,
|
|
649
649
|
resolveResume: (cwd: string) => { ok: boolean; ref?: string; reason?: string },
|
|
650
650
|
incomingTopic?: string,
|
|
651
|
+
/** OC14-1 — marker CONSUMPTION is deferred to the caller: a failed bring-up must not change the
|
|
652
|
+
* next resume/fresh decision (a crash-looping binary used to eat .idle-reaped, and after the fix the
|
|
653
|
+
* peer came back FRESH, its context lost). Absent → consume immediately (legacy/test callers). */
|
|
654
|
+
defer?: (consume: () => void) => void,
|
|
651
655
|
): WakeMode {
|
|
656
|
+
const done = defer ?? ((f: () => void) => f())
|
|
652
657
|
// 1. folder-launch → always fresh. Consume a standing .new-eager too — the mark's
|
|
653
658
|
// promise ("next bring-up is FRESH") is fulfilled by this launch; leaving it would
|
|
654
659
|
// leak an eager relaunch into a LATER, unrelated death.
|
|
655
660
|
if (argsResume === false) {
|
|
656
|
-
clearNewEager(cfg, identity)
|
|
661
|
+
done(() => clearNewEager(cfg, identity))
|
|
657
662
|
return { resume: false, cause: 'folder-launch' }
|
|
658
663
|
}
|
|
659
664
|
// 1.5 — a standing .new-eager OUTRANKS EVERYTHING below, including attach-resume
|
|
@@ -664,8 +669,8 @@ export function resolveWakeMode(
|
|
|
664
669
|
// finds it must honor it. Consume-on-fire + clear .idle-reaped (mirror .fresh-next
|
|
665
670
|
// hygiene: a stale clean-park marker must not make a later crash read as resume).
|
|
666
671
|
if (hasNewEager(cfg, identity)) {
|
|
667
|
-
clearNewEager(cfg, identity)
|
|
668
|
-
clearIdleReaped(cfg, identity)
|
|
672
|
+
done(() => clearNewEager(cfg, identity))
|
|
673
|
+
done(() => clearIdleReaped(cfg, identity))
|
|
669
674
|
return { resume: false, cause: 'self-fresh-eager' }
|
|
670
675
|
}
|
|
671
676
|
// 2. attach → resume, fail-loud if nothing to resume.
|
|
@@ -682,8 +687,8 @@ export function resolveWakeMode(
|
|
|
682
687
|
// .fresh-next AND .idle-reaped (mirror the ephemeral hygiene below — else a stale .idle-reaped would make
|
|
683
688
|
// a later CRASH read as a clean park in 3b → RESUME a killed context, exactly the 3a hazard).
|
|
684
689
|
if (hasFreshNext(cfg, identity)) {
|
|
685
|
-
clearFreshNext(cfg, identity)
|
|
686
|
-
clearIdleReaped(cfg, identity)
|
|
690
|
+
done(() => clearFreshNext(cfg, identity))
|
|
691
|
+
done(() => clearIdleReaped(cfg, identity))
|
|
687
692
|
return { resume: false, cause: 'soft-reload' }
|
|
688
693
|
}
|
|
689
694
|
// 3. default (a message woke a dead/asleep peer): decide by the death cause.
|
|
@@ -692,7 +697,7 @@ export function resolveWakeMode(
|
|
|
692
697
|
// Consume a stray .idle-reaped marker so it does not accumulate (it has no effect for
|
|
693
698
|
// an ephemeral peer, which never resumes, but keep state tidy).
|
|
694
699
|
if (isEphemeralPeer(cwd)) {
|
|
695
|
-
clearIdleReaped(cfg, identity)
|
|
700
|
+
done(() => clearIdleReaped(cfg, identity))
|
|
696
701
|
return { resume: false, cause: 'ephemeral-policy' }
|
|
697
702
|
}
|
|
698
703
|
// 3a. NOT parked-clean → FRESH either way, but tell the two cases apart in the
|
|
@@ -705,7 +710,7 @@ export function resolveWakeMode(
|
|
|
705
710
|
: { resume: false, cause: 'first-wake' }
|
|
706
711
|
}
|
|
707
712
|
// 3b. idle-reaped → resume-eligible. Consume the marker now (it has done its job).
|
|
708
|
-
clearIdleReaped(cfg, identity)
|
|
713
|
+
done(() => clearIdleReaped(cfg, identity))
|
|
709
714
|
// human-conversational dialogue never auto-freshes; only an explicit /new resets it.
|
|
710
715
|
if (isHumanConversational(cwd)) {
|
|
711
716
|
const r = resolveResume(cwd)
|
|
@@ -1107,7 +1112,27 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1107
1112
|
// by the death cause (.idle-reaped marker) + peer-type/topic. An EXPLICIT resume
|
|
1108
1113
|
// that finds nothing to resume fails loud. incomingTopic (args.topic) is the
|
|
1109
1114
|
// executor discriminator.
|
|
1110
|
-
|
|
1115
|
+
// OC14-1 — marker consumption is applied only once the session is actually up (see below): a
|
|
1116
|
+
// failed bring-up leaves .idle-reaped / .fresh-next / .new-eager intact for the next attempt.
|
|
1117
|
+
const consumeOnUp: Array<() => void> = []
|
|
1118
|
+
const mode = resolveWakeMode(
|
|
1119
|
+
cfg,
|
|
1120
|
+
identity,
|
|
1121
|
+
cwd,
|
|
1122
|
+
args.resume,
|
|
1123
|
+
c => adapter.resolveResume(c, { personality: args.personality, env }),
|
|
1124
|
+
args.topic,
|
|
1125
|
+
f => consumeOnUp.push(f),
|
|
1126
|
+
)
|
|
1127
|
+
const applyConsumed = (): void => {
|
|
1128
|
+
for (const f of consumeOnUp.splice(0)) {
|
|
1129
|
+
try {
|
|
1130
|
+
f()
|
|
1131
|
+
} catch {
|
|
1132
|
+
/* best-effort marker hygiene — never fails a wake */
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1111
1136
|
// The bring-up decision is the durable trace — log it BEFORE launch (the decision
|
|
1112
1137
|
// stands regardless of whether the subsequent launch succeeds). resolveWakeMode has
|
|
1113
1138
|
// already consumed any .idle-reaped marker, so `cause` is now its only record.
|
|
@@ -1193,6 +1218,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1193
1218
|
// must still hear FAILED; we only make the live remnant supervised. Best-effort: a write
|
|
1194
1219
|
// hiccup must not mask the original FAILED reason.
|
|
1195
1220
|
if (sessionAlive(sock, identity, env)) {
|
|
1221
|
+
applyConsumed() // a live remnant IS this bring-up's session — its markers are spent
|
|
1196
1222
|
try {
|
|
1197
1223
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
1198
1224
|
} catch { /* keep the original FAILED reason — enroll is best-effort */ }
|
|
@@ -1207,6 +1233,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1207
1233
|
}
|
|
1208
1234
|
return { status: 'FAILED', woke: false, runtime, reason: result.reason }
|
|
1209
1235
|
}
|
|
1236
|
+
applyConsumed()
|
|
1210
1237
|
// The session is up and the message delivered. Recording supervise-state must not
|
|
1211
1238
|
// turn a successful wake into a failure (audit #18): log loudly rather than throw
|
|
1212
1239
|
// past a live spawn. NB since 0.2.55 (self-TTL removed) the session-state file IS
|
|
@@ -42,6 +42,7 @@ export const UNLANDED_AFTER_MS = 120_000
|
|
|
42
42
|
|
|
43
43
|
const inflightPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.inflight.jsonl`)
|
|
44
44
|
const orphansPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.orphans.jsonl`)
|
|
45
|
+
const interruptedPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.interrupted.jsonl`)
|
|
45
46
|
|
|
46
47
|
function readJsonl(path: string): InflightEntry[] {
|
|
47
48
|
if (!existsSync(path)) return []
|
|
@@ -90,6 +91,8 @@ export interface InflightSweepDeps {
|
|
|
90
91
|
sessionToken: (identity: string) => string | null
|
|
91
92
|
/** Did the receiving session answer this entry? */
|
|
92
93
|
processed: (entry: InflightEntry) => boolean
|
|
94
|
+
/** Did the turn this entry started FINISH? Absent → processed counts as finished (legacy). */
|
|
95
|
+
completed?: (entry: InflightEntry) => boolean
|
|
93
96
|
/** Did the entry land in the session at all? Only consulted for `unconfirmed` entries. */
|
|
94
97
|
landed?: (entry: InflightEntry) => boolean
|
|
95
98
|
now?: number
|
|
@@ -106,14 +109,35 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
|
|
|
106
109
|
const current = deps.sessionToken(identity)
|
|
107
110
|
const keep: InflightEntry[] = []
|
|
108
111
|
const orphaned: InflightEntry[] = []
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
const interrupted: InflightEntry[] = []
|
|
113
|
+
const safe = (f: ((e: InflightEntry) => boolean) | undefined, e: InflightEntry, dflt: boolean): boolean => {
|
|
114
|
+
if (!f) return dflt
|
|
111
115
|
try {
|
|
112
|
-
|
|
116
|
+
return f(e)
|
|
113
117
|
} catch {
|
|
114
|
-
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// A session works its input in order: a FINISHED turn for a later message means every earlier one
|
|
122
|
+
// was handled too (bounds the ledger when a turn-end record is missed).
|
|
123
|
+
let finishedUpTo = -Infinity
|
|
124
|
+
const finished = new Set<string>()
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
if (safe(deps.completed, e, false) || (!deps.completed && safe(deps.processed, e, false))) {
|
|
127
|
+
finished.add(e.id)
|
|
128
|
+
finishedUpTo = Math.max(finishedUpTo, e.ts)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const e of entries) {
|
|
132
|
+
if (finished.has(e.id) || e.ts < finishedUpTo) continue
|
|
133
|
+
const done = safe(deps.processed, e, false)
|
|
134
|
+
if (done) {
|
|
135
|
+
// Answered but its turn did not finish: live → still working; gone → INTERRUPTED (side effects
|
|
136
|
+
// may have happened — never redelivered, the sender is told).
|
|
137
|
+
if (current !== null && current === e.sessionToken) keep.push(e)
|
|
138
|
+
else interrupted.push(e)
|
|
139
|
+
continue
|
|
115
140
|
}
|
|
116
|
-
if (done) continue
|
|
117
141
|
if (current !== null && current === e.sessionToken) {
|
|
118
142
|
// Unknown landing (confirm window ran out): once seen in the session it is a normal pending entry;
|
|
119
143
|
// still unseen after UNLANDED_AFTER_MS → the paste was swallowed → orphan (redeliver once).
|
|
@@ -134,7 +158,8 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
|
|
|
134
158
|
}
|
|
135
159
|
writeJsonl(inflightPath(stateDir, identity), keep)
|
|
136
160
|
if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
|
|
137
|
-
|
|
161
|
+
if (interrupted.length) for (const o of interrupted) appendFileSync(interruptedPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
|
|
162
|
+
return orphaned.length + interrupted.length
|
|
138
163
|
}
|
|
139
164
|
|
|
140
165
|
export function listOrphanIdentities(stateDir: string): string[] {
|
|
@@ -155,8 +180,27 @@ export function takeOrphans(stateDir: string, identity: string): InflightEntry[]
|
|
|
155
180
|
return entries
|
|
156
181
|
}
|
|
157
182
|
|
|
183
|
+
export function listInterruptedIdentities(stateDir: string): string[] {
|
|
184
|
+
let names: string[]
|
|
185
|
+
try {
|
|
186
|
+
names = readdirSync(stateDir)
|
|
187
|
+
} catch {
|
|
188
|
+
return []
|
|
189
|
+
}
|
|
190
|
+
return names.filter(n => n.endsWith('.interrupted.jsonl')).map(n => n.slice(0, -'.interrupted.jsonl'.length))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Take (and remove) an identity's interrupted entries — the caller notifies each sender. */
|
|
194
|
+
export function takeInterrupted(stateDir: string, identity: string): InflightEntry[] {
|
|
195
|
+
const path = interruptedPath(stateDir, identity)
|
|
196
|
+
const entries = readJsonl(path)
|
|
197
|
+
rmSync(path, { force: true })
|
|
198
|
+
return entries
|
|
199
|
+
}
|
|
200
|
+
|
|
158
201
|
/** Remove an identity's ledger + orphans (peer removal). */
|
|
159
202
|
export function purgeInflight(stateDir: string, identity: string): void {
|
|
160
203
|
rmSync(inflightPath(stateDir, identity), { force: true })
|
|
161
204
|
rmSync(orphansPath(stateDir, identity), { force: true })
|
|
205
|
+
rmSync(interruptedPath(stateDir, identity), { force: true })
|
|
162
206
|
}
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
// message delivery (acknowledged only once THIS message is persisted natively),
|
|
9
9
|
// interrupt and TUI session selection.
|
|
10
10
|
// Plain ESM, no dependencies: it runs inside OpenCode's own server worker.
|
|
11
|
-
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
12
12
|
import { createServer } from 'node:net'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
13
14
|
import { join } from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
14
16
|
|
|
15
17
|
const SCHEMA = 1
|
|
16
18
|
const DELIVER_ACK_MS = 15000
|
|
@@ -296,6 +298,66 @@ async function server(input) {
|
|
|
296
298
|
control.listen(paths.socket, () => resolve())
|
|
297
299
|
})
|
|
298
300
|
|
|
301
|
+
// ── launch self-check (OC04): problems OpenCode itself tolerates silently ─────
|
|
302
|
+
// A missing / throwing plugin from a fragment and an unreachable MCP server still give a «working»
|
|
303
|
+
// session with surfaces silently absent. They are reported in runtime.ready.properties.warnings and
|
|
304
|
+
// state.warnings — never a refusal (the session is usable), never silent.
|
|
305
|
+
function tailText(path, bytes) {
|
|
306
|
+
try {
|
|
307
|
+
const size = statSync(path).size
|
|
308
|
+
const fd = openSync(path, 'r')
|
|
309
|
+
try {
|
|
310
|
+
const start = Math.max(0, size - bytes)
|
|
311
|
+
const buf = Buffer.alloc(size - start)
|
|
312
|
+
readSync(fd, buf, 0, buf.length, start)
|
|
313
|
+
return buf.toString('utf8')
|
|
314
|
+
} finally {
|
|
315
|
+
closeSync(fd)
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
return ''
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function selfCheck() {
|
|
322
|
+
const warnings = []
|
|
323
|
+
try {
|
|
324
|
+
const st = await client.mcp.status()
|
|
325
|
+
for (const [name, s] of Object.entries((st && st.data) || {})) {
|
|
326
|
+
if (s && s.status !== 'connected' && s.status !== 'disabled') warnings.push('mcp ' + name + ': ' + s.status + (s.error ? ' — ' + String(s.error).slice(0, 200) : ''))
|
|
327
|
+
}
|
|
328
|
+
} catch (e) {
|
|
329
|
+
warnings.push('mcp status unavailable: ' + (e instanceof Error ? e.message : String(e)))
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const cfg = await client.config.get()
|
|
333
|
+
for (const spec of ((cfg && cfg.data && cfg.data.plugin) || [])) {
|
|
334
|
+
if (typeof spec === 'string' && spec.startsWith('file://') && !existsSync(fileURLToPath(spec))) warnings.push('plugin missing: ' + spec)
|
|
335
|
+
}
|
|
336
|
+
} catch {}
|
|
337
|
+
// Plugin load failures are only in the native log (one shared file, lines tagged run=<id>): find
|
|
338
|
+
// THIS process's run id through a marker line, then its «failed to load plugin» lines.
|
|
339
|
+
try {
|
|
340
|
+
const marker = 'iapeer-selfcheck ' + launchID
|
|
341
|
+
await client.app.log({ body: { service: 'iapeer', level: 'warn', message: marker } })
|
|
342
|
+
const logFile = join(env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share'), 'opencode', 'log', 'opencode.log')
|
|
343
|
+
let text = ''
|
|
344
|
+
for (let i = 0; i < 10 && !text.includes(marker); i++) {
|
|
345
|
+
await new Promise(r => setTimeout(r, 100))
|
|
346
|
+
text = tailText(logFile, 4 * 1024 * 1024)
|
|
347
|
+
}
|
|
348
|
+
const mine = text.split('\n').find(l => l.includes(marker))
|
|
349
|
+
const run = mine && (mine.match(/ run=([^ ]+)/) || [])[1]
|
|
350
|
+
if (run) {
|
|
351
|
+
for (const l of text.split('\n')) {
|
|
352
|
+
if (l.includes(' run=' + run + ' ') && /failed to load plugin|Failed to load plugin|plugin config hook failed/.test(l)) {
|
|
353
|
+
warnings.push('plugin failed: ' + l.replace(/^.*?message=/, '').slice(0, 300))
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch {}
|
|
358
|
+
return warnings
|
|
359
|
+
}
|
|
360
|
+
|
|
299
361
|
// Readiness runs AFTER init returns: calling the in-process client while OpenCode is
|
|
300
362
|
// still loading this plugin would wait on the very instance state being built.
|
|
301
363
|
// The socket is up and the session is known (fresh: created now) → runtime.ready —
|
|
@@ -310,8 +372,11 @@ async function server(input) {
|
|
|
310
372
|
const hist = await client.session.messages({ path: { id: sessionID } }).catch(() => null)
|
|
311
373
|
for (const m of (hist && hist.data) || []) noteModel(m && m.info)
|
|
312
374
|
}
|
|
375
|
+
const warnings = await selfCheck()
|
|
376
|
+
if (warnings.length) state.warnings = warnings
|
|
377
|
+
else delete state.warnings
|
|
313
378
|
if (state.status === 'starting') state.status = 'idle'
|
|
314
|
-
journal('runtime.ready', { session: got.data, resumed }, null, sessionID)
|
|
379
|
+
journal('runtime.ready', { session: got.data, resumed, ...(warnings.length ? { warnings } : {}) }, null, sessionID)
|
|
315
380
|
} catch (e) {
|
|
316
381
|
state.status = 'error'
|
|
317
382
|
state.error = e instanceof Error ? e.message : String(e)
|
package/src/opencode/journal.ts
CHANGED
|
@@ -19,6 +19,8 @@ export interface OpencodeState {
|
|
|
19
19
|
error?: string
|
|
20
20
|
/** Actual `provider/model` of the last assistant turn (plugin-reported). */
|
|
21
21
|
model?: string
|
|
22
|
+
/** Launch self-check findings (missing/failed plugin, MCP not connected) — docs/20 OC04. */
|
|
23
|
+
warnings?: string[]
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface OpencodeJournalLine {
|
package/src/transport/index.ts
CHANGED
|
@@ -903,6 +903,76 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
|
|
|
903
903
|
return false
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
+
/**
|
|
907
|
+
* Did the receiving session FINISH the turn this delivery started (OC07 extension)? Processed (a model
|
|
908
|
+
* response began) is not finished: a SIGKILL mid-tool leaves a processed-but-unfinished turn whose side
|
|
909
|
+
* effects may have happened — it must not be redelivered, and its sender must be told. Proof of a
|
|
910
|
+
* finished turn after the envelope's record:
|
|
911
|
+
* claude — an assistant record with a terminal stop_reason (end_turn / stop_sequence / max_tokens);
|
|
912
|
+
* codex — an `event_msg` `task_complete`;
|
|
913
|
+
* opencode — an assistant message for that user message with `time.completed` and a finish other
|
|
914
|
+
* than `tool-calls`.
|
|
915
|
+
*/
|
|
916
|
+
export function deliveryCompleted(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
917
|
+
const needles = confirmNeedles(probe.rendered)
|
|
918
|
+
if (!needles.length || needles.every(n => !n)) return false
|
|
919
|
+
if (getAdapter(probe.runtime).deliveryConfirm === 'structural') {
|
|
920
|
+
const dir = endpointPrivateDir(probe.runtime, probe.personality, { env })
|
|
921
|
+
if (!dir) return false
|
|
922
|
+
const path = join(dir, 'events.jsonl')
|
|
923
|
+
let size = 0
|
|
924
|
+
try {
|
|
925
|
+
size = statSync(path).size
|
|
926
|
+
} catch {
|
|
927
|
+
return false
|
|
928
|
+
}
|
|
929
|
+
const userIDs = new Set<string>()
|
|
930
|
+
for (const line of readFileTailFrom(path, Math.max(0, size - PROCESSED_TAIL_BYTES)).split('\n')) {
|
|
931
|
+
if (!line.trim()) continue
|
|
932
|
+
let e: { type?: string; properties?: { part?: { type?: string; text?: string; messageID?: string }; info?: { role?: string; parentID?: string; finish?: string; time?: { completed?: number } } } }
|
|
933
|
+
try {
|
|
934
|
+
e = JSON.parse(line)
|
|
935
|
+
} catch {
|
|
936
|
+
continue
|
|
937
|
+
}
|
|
938
|
+
const part = e.properties?.part
|
|
939
|
+
if (e.type === 'message.part.updated' && part?.type === 'text' && typeof part.text === 'string' && part.messageID) {
|
|
940
|
+
const text = part.text.replace(/\r/g, '')
|
|
941
|
+
if (needles.every(n => text.includes(n))) userIDs.add(part.messageID)
|
|
942
|
+
}
|
|
943
|
+
const info = e.properties?.info
|
|
944
|
+
if (e.type === 'message.updated' && info?.role === 'assistant' && info.parentID && userIDs.has(info.parentID) && info.time?.completed && info.finish !== 'tool-calls') return true
|
|
945
|
+
}
|
|
946
|
+
return false
|
|
947
|
+
}
|
|
948
|
+
for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
|
|
949
|
+
let st
|
|
950
|
+
try {
|
|
951
|
+
st = statSync(path)
|
|
952
|
+
} catch {
|
|
953
|
+
continue
|
|
954
|
+
}
|
|
955
|
+
if (st.mtimeMs < probe.ts - 5000) continue
|
|
956
|
+
let carried = false
|
|
957
|
+
for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
|
|
958
|
+
if (!line.trim()) continue
|
|
959
|
+
let o: { type?: string; message?: { stop_reason?: string }; payload?: { type?: string } }
|
|
960
|
+
try {
|
|
961
|
+
o = JSON.parse(line)
|
|
962
|
+
} catch {
|
|
963
|
+
continue
|
|
964
|
+
}
|
|
965
|
+
if (!carried) {
|
|
966
|
+
if (o.type !== 'queue-operation' && jsonStringValuesContainAll(o, needles)) carried = true
|
|
967
|
+
continue
|
|
968
|
+
}
|
|
969
|
+
if (o.type === 'assistant' && ['end_turn', 'stop_sequence', 'max_tokens'].includes(o.message?.stop_reason ?? '')) return true
|
|
970
|
+
if (o.type === 'event_msg' && o.payload?.type === 'task_complete') return true
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return false
|
|
974
|
+
}
|
|
975
|
+
|
|
906
976
|
/**
|
|
907
977
|
* Did the envelope LAND in the receiving session at all (any record carrying it, including claude's
|
|
908
978
|
* queue-operation for a busy session)? Weaker than deliveryProcessed — it separates «queued / being
|