@agfpd/iapeer 0.4.120 → 0.4.122
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 +34 -0
- package/package.json +1 -1
- package/src/cli/index.ts +121 -0
- package/src/daemon/fleet.ts +12 -3
- package/src/daemon/main.ts +44 -15
- 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 +76 -8
- package/src/opencode/iapeer-plugin.mjs +67 -2
- package/src/opencode/journal.ts +2 -0
- package/src/transport/index.ts +131 -3
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,23 @@ 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`.
|
|
204
|
+
- Unknown is not failed. When a live receiver's acceptance record does not appear
|
|
205
|
+
within the confirm window (a busy claude records a queued message late), the
|
|
206
|
+
send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
|
|
207
|
+
sender must not resend. The ledger keeps the entry marked unconfirmed: once any
|
|
208
|
+
record carrying the envelope appears (including a claude `queue-operation`) it
|
|
209
|
+
is an ordinary pending entry; if none appears within 120 s the paste was
|
|
210
|
+
swallowed, and the ledger redelivers it. Each message is redelivered at most
|
|
211
|
+
once; a second miss becomes an explicit notice to the sender.
|
|
178
212
|
|
|
179
213
|
The real death cause is recorded too: the supervisor waits for the reap, so a
|
|
180
214
|
SIGKILL is logged as `dead_signal=SIGKILL`, never as a clean exit 0.
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -900,6 +900,117 @@ export async function compactPeer(
|
|
|
900
900
|
// ephemeral zombie records a retired spawn leaves behind.
|
|
901
901
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
902
902
|
|
|
903
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
904
|
+
// remove-runtime — the symmetric undo of add-runtime (one endpoint, not the peer)
|
|
905
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
906
|
+
|
|
907
|
+
export interface RemoveRuntimeOutcome {
|
|
908
|
+
personality: string
|
|
909
|
+
runtime: string
|
|
910
|
+
action: 'removed' | 'absent' | 'refused-default' | 'refused-last' | 'refused-live' | 'refused-infra'
|
|
911
|
+
reason?: string
|
|
912
|
+
/** What was undone, one line per surface. */
|
|
913
|
+
steps?: string[]
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Detach ONE runtime from a peer (OC14-2 — the pilot rollback of `add-runtime --all`). Undoes what
|
|
918
|
+
* add-runtime did for that endpoint, in this order: refuse unsafe cases (the default runtime, the last
|
|
919
|
+
* runtime, a live session unless --force, an infra channel — its plist teardown is `remove`'s job);
|
|
920
|
+
* unprovision the memory slot and every capability for THIS runtime (occasion=remove); runtime cleanup
|
|
921
|
+
* (opencode: its core private dir; codex: cwd trust); park the cwd discovery markers (`.claude`/`.codex`
|
|
922
|
+
* and `.iapeer/runtimes/<rt>`) as `<name>.removed-<ts>` so re-provision does not rediscover the
|
|
923
|
+
* runtime — data is kept, nothing is deleted from the peer folder; drop it from the local profile
|
|
924
|
+
* (source of truth) and re-project the registry; purge the endpoint's lifecycle state.
|
|
925
|
+
*/
|
|
926
|
+
export async function removeRuntimeCli(
|
|
927
|
+
runtime: string,
|
|
928
|
+
opts: CliEnvOptions & { peer: string; force?: boolean },
|
|
929
|
+
): Promise<RemoveRuntimeOutcome> {
|
|
930
|
+
const env = opts.env ?? process.env
|
|
931
|
+
const personality = opts.peer
|
|
932
|
+
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
933
|
+
if (!peer) throw new Error(`peer "${personality}" is not registered`)
|
|
934
|
+
if (!peer.runtimes.includes(runtime)) return { personality, runtime, action: 'absent', reason: `"${personality}" does not declare ${runtime}` }
|
|
935
|
+
if (isInfraRuntime(runtime)) {
|
|
936
|
+
return { personality, runtime, action: 'refused-infra', reason: `"${runtime}" is an always-on channel (launchd plist) — not supported by remove-runtime yet` }
|
|
937
|
+
}
|
|
938
|
+
if (peer.runtimes.length === 1) {
|
|
939
|
+
return { personality, runtime, action: 'refused-last', reason: `${runtime} is the only runtime of "${personality}" — use \`iapeer remove ${personality}\`` }
|
|
940
|
+
}
|
|
941
|
+
if (peer.runtime === runtime) {
|
|
942
|
+
return {
|
|
943
|
+
personality,
|
|
944
|
+
runtime,
|
|
945
|
+
action: 'refused-default',
|
|
946
|
+
reason: `${runtime} is the default runtime of "${personality}" — switch it first: iapeer default-runtime <other> --peer ${personality}`,
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const cfg = loadLifecycleConfig(env)
|
|
950
|
+
const identity = buildProcessAddress(runtime as Runtime, personality)
|
|
951
|
+
const steps: string[] = []
|
|
952
|
+
if (isPeerLive(runtime, personality, cfg.sockDir, env)) {
|
|
953
|
+
if (!opts.force) {
|
|
954
|
+
return { personality, runtime, action: 'refused-live', reason: `${identity} is LIVE — stop it first (iapeer stop ${personality} ${runtime}) or pass --force` }
|
|
955
|
+
}
|
|
956
|
+
const { killPtyHost } = await import('../launch/ptyHost.ts')
|
|
957
|
+
killPtyHost(identity, env)
|
|
958
|
+
steps.push('live session stopped (--force)')
|
|
959
|
+
}
|
|
960
|
+
// Slots for THIS endpoint (provider + capabilities), while the peer's state is still consistent.
|
|
961
|
+
try {
|
|
962
|
+
const slot = readMemoryProvider(env)
|
|
963
|
+
if (slot?.unprovision) {
|
|
964
|
+
const { runProvisionCommand } = await import('../enable/provisionCommand.ts')
|
|
965
|
+
const o = runProvisionCommand({ block: slot.unprovision, cwd: peer.cwd, runtime, personality, occasion: 'remove', env })
|
|
966
|
+
steps.push(`memory unprovision: ${o.state}${o.state !== 'ok' && o.detail ? ` (${o.detail})` : ''}`)
|
|
967
|
+
}
|
|
968
|
+
} catch (e) {
|
|
969
|
+
steps.push(`memory unprovision: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
const { runCapabilityLifecycle } = await import('../capability/index.ts')
|
|
973
|
+
for (const run of runCapabilityLifecycle({ kind: 'unprovision', personality, cwd: peer.cwd, runtimes: [runtime], occasion: 'remove', env })) {
|
|
974
|
+
steps.push(`capability ${run.capability} unprovision: ${run.outcome.state}`)
|
|
975
|
+
}
|
|
976
|
+
} catch (e) {
|
|
977
|
+
steps.push(`capabilities: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
978
|
+
}
|
|
979
|
+
if (runtime === 'opencode') {
|
|
980
|
+
const { opencodePaths } = await import('../opencode/config.ts')
|
|
981
|
+
const dir = opencodePaths(personality, { env }).dir
|
|
982
|
+
if (existsSync(dir)) {
|
|
983
|
+
rmSync(dir, { recursive: true, force: true })
|
|
984
|
+
steps.push(`opencode private dir removed: ${dir}`)
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (runtime === 'codex') {
|
|
988
|
+
try {
|
|
989
|
+
const { removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
|
|
990
|
+
steps.push(`codex cwd trust: ${removeCodexCwdTrust(peer.cwd, env).state}`)
|
|
991
|
+
} catch (e) {
|
|
992
|
+
steps.push(`codex cwd trust: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '')
|
|
996
|
+
for (const marker of [join(peer.cwd, `.${runtime}`), join(peer.cwd, '.iapeer', 'runtimes', runtime)]) {
|
|
997
|
+
if (!existsSync(marker)) continue
|
|
998
|
+
const parked = `${marker}.removed-${stamp}`
|
|
999
|
+
renameSync(marker, parked)
|
|
1000
|
+
steps.push(`marker parked (data kept): ${parked}`)
|
|
1001
|
+
}
|
|
1002
|
+
const profile = readPeerProfile(peer.cwd)
|
|
1003
|
+
if (profile) {
|
|
1004
|
+
writePeerProfileAtomic(peer.cwd, { ...profile, runtimes: profile.runtimes.filter(r => r !== runtime) })
|
|
1005
|
+
steps.push('local profile: runtime removed')
|
|
1006
|
+
}
|
|
1007
|
+
const { reindexFromLocals } = await import('../identity/profileStandard.ts')
|
|
1008
|
+
await reindexFromLocals({ env })
|
|
1009
|
+
const purged = purgeIdentityState(cfg, identity)
|
|
1010
|
+
if (purged.length) steps.push(`lifecycle state purged: ${purged.join(', ')}`)
|
|
1011
|
+
return { personality, runtime, action: 'removed', steps }
|
|
1012
|
+
}
|
|
1013
|
+
|
|
903
1014
|
export interface RemoveOutcome {
|
|
904
1015
|
personality: string
|
|
905
1016
|
action: 'removed' | 'absent' | 'refused-live' | 'refused-foreign-launchd'
|
|
@@ -1605,6 +1716,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1605
1716
|
sig: 'tray install|uninstall|render [--stream]|cmd <c> <peer>|approve <id>|deny <id> [reason]|status',
|
|
1606
1717
|
desc: 'the macOS menu-bar fleet dashboard (SwiftBar plugin) — first external Fleet API client; renders the approval queue (badge + Allow/Deny), install activates it (installs SwiftBar when absent). See docs/16.',
|
|
1607
1718
|
},
|
|
1719
|
+
{ sig: 'remove-runtime <runtime> --peer <p> [--force]', desc: 'detach ONE agentic runtime from a peer (the undo of add-runtime): unprovision memory + capabilities for it, runtime cleanup (opencode private dir, codex trust), park its cwd markers as *.removed-<ts> (data kept), drop it from the profile + registry, purge its lifecycle state. Refuses the default runtime, the last runtime, a live session (unless --force) and infra channels' },
|
|
1608
1720
|
{ sig: 'private-dir <peer> [--runtime r]', desc: 'print the core-owned private directory of a peer endpoint (docs/20: opencode journal/state/fragments). The ONLY way a process not launched by the core gets the path — never derive the layout. Without --runtime: the single declared runtime that has one. Exit 1 when none' },
|
|
1609
1721
|
{ sig: 'approval-mode <peer> [gated|yolo]', desc: 'read/flip a peer\'s human-approval mode (docs/17): yolo=current bypass; gated=blocking runtime approvals routed to a human. Persists + brings runtime surfaces to the mode; applies on next fresh session' },
|
|
1610
1722
|
{ sig: 'approvals [--json]', desc: 'list pending human-approval requests (verbatim action content); the host answer channel to the daemon broker' },
|
|
@@ -2310,6 +2422,15 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2310
2422
|
out(`${endpointPrivateDir(candidates[0]!, name, { env })}\n`)
|
|
2311
2423
|
return 0
|
|
2312
2424
|
}
|
|
2425
|
+
case 'remove-runtime': {
|
|
2426
|
+
const rt = positionals[0]
|
|
2427
|
+
const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
|
|
2428
|
+
if (!rt || !peerName) return argErr(errOut, 'remove-runtime needs a runtime and a peer — usage: iapeer remove-runtime <runtime> --peer <p> [--force]')
|
|
2429
|
+
const o = await removeRuntimeCli(rt, { peer: peerName, force: flags.force === true, env })
|
|
2430
|
+
out(`${o.personality}: ${o.runtime} ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
2431
|
+
for (const step of o.steps ?? []) out(` ${step}\n`)
|
|
2432
|
+
return o.action === 'removed' || o.action === 'absent' ? 0 : 1
|
|
2433
|
+
}
|
|
2313
2434
|
case 'default-runtime': {
|
|
2314
2435
|
// The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
|
|
2315
2436
|
const rt = positionals[0]
|
package/src/daemon/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 => {
|
package/src/daemon/main.ts
CHANGED
|
@@ -56,6 +56,8 @@ import type { ResolvedCaller } from '../identity/index.ts'
|
|
|
56
56
|
import { findPeer, readPeersIndex } from '../registry/index.ts'
|
|
57
57
|
import {
|
|
58
58
|
createComposerDeliveryQueue,
|
|
59
|
+
deliveryCompleted,
|
|
60
|
+
deliveryLanded,
|
|
59
61
|
deliveryProcessed,
|
|
60
62
|
routeSend,
|
|
61
63
|
type InflightNote,
|
|
@@ -77,7 +79,7 @@ import { startGoalWatch } from './goalwatch.ts'
|
|
|
77
79
|
import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
|
|
78
80
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
79
81
|
import { randomBytes } from 'crypto'
|
|
80
|
-
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'
|
|
81
83
|
import { hostSessionAlive, hostSessionToken } from '../launch/ptyHost.ts'
|
|
82
84
|
|
|
83
85
|
/** Default TCP loopback port for the always-on router. Real http MCP clients
|
|
@@ -265,8 +267,20 @@ export function makeNoteInflight(cfg: LifecycleConfig): (note: InflightNote) =>
|
|
|
265
267
|
}
|
|
266
268
|
|
|
267
269
|
/** The redelivery prefix — the model sees it is a re-send, not a duplicate of something it answered. */
|
|
270
|
+
const REDELIVERY_PREFIX = '[redelivered by iapeer:'
|
|
268
271
|
export function redeliveryMessage(e: InflightEntry): string {
|
|
269
|
-
|
|
272
|
+
const why = e.unconfirmed ? 'it never reached the receiving session' : 'the receiving session ended before answering this message'
|
|
273
|
+
return `${REDELIVERY_PREFIX} ${why}; originally sent ${e.sentAt}]\n${e.message}`
|
|
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
|
+
)
|
|
270
284
|
}
|
|
271
285
|
|
|
272
286
|
/** Body of the explicit non-delivery notice to the original sender. */
|
|
@@ -289,22 +303,48 @@ export async function processInflight(
|
|
|
289
303
|
cfg: LifecycleConfig,
|
|
290
304
|
env: NodeJS.ProcessEnv,
|
|
291
305
|
routeDeps: RouteDeps,
|
|
292
|
-
seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean } = {},
|
|
306
|
+
seam: { send?: typeof routeSend; processed?: (e: InflightEntry) => boolean; landed?: (e: InflightEntry) => boolean; completed?: (e: InflightEntry) => boolean } = {},
|
|
293
307
|
): Promise<void> {
|
|
294
308
|
const processed = seam.processed ?? ((e: InflightEntry) => deliveryProcessed(e, env))
|
|
295
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
|
+
}
|
|
296
323
|
for (const identity of listInflightIdentities(cfg.stateDir)) {
|
|
297
324
|
const n = sweepInflight(cfg.stateDir, identity, {
|
|
298
325
|
sessionToken: id => (hostSessionAlive(id, env) ? hostSessionToken(id, env) : null),
|
|
299
326
|
processed,
|
|
327
|
+
completed: seam.completed ?? ((e: InflightEntry) => deliveryCompleted(e, env)),
|
|
328
|
+
landed: seam.landed ?? ((e: InflightEntry) => deliveryLanded(e, env)),
|
|
300
329
|
})
|
|
301
330
|
if (n > 0) appendLifecycleEvent(cfg.eventLogDir, { ev: 'inflight-orphaned', identity, count: n }, { env })
|
|
302
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
|
+
}
|
|
303
340
|
for (const identity of listOrphanIdentities(cfg.stateDir)) {
|
|
304
341
|
for (const e of takeOrphans(cfg.stateDir, identity)) {
|
|
305
342
|
const index = readPeersIndex({ env })
|
|
306
343
|
let reason: string
|
|
307
344
|
try {
|
|
345
|
+
// ONE redelivery per message: an already-redelivered message that orphans again goes to the
|
|
346
|
+
// sender as an explicit notice instead of looping.
|
|
347
|
+
if (e.message.startsWith(REDELIVERY_PREFIX)) throw new Error('already redelivered once and still not processed')
|
|
308
348
|
const caller = resolveCallerIdentity({ personality: e.caller.personality, runtime: e.caller.runtime }, index)
|
|
309
349
|
const r = await send(
|
|
310
350
|
caller,
|
|
@@ -320,18 +360,7 @@ export async function processInflight(
|
|
|
320
360
|
reason = x instanceof Error ? x.message : String(x)
|
|
321
361
|
}
|
|
322
362
|
// Redelivery failed → the sender must learn it explicitly.
|
|
323
|
-
|
|
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
|
-
}
|
|
363
|
+
const notified = await notifySender(e, undeliveredNotice(e, reason))
|
|
335
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 })
|
|
336
365
|
}
|
|
337
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
|
|
@@ -32,10 +32,17 @@ export interface InflightEntry {
|
|
|
32
32
|
attachments?: string[]
|
|
33
33
|
/** The presented envelope (what the model sees) — the processed-check needle source. */
|
|
34
34
|
rendered: string
|
|
35
|
+
/** The live confirm window ran out at delivery: whether it LANDED is still unknown. */
|
|
36
|
+
unconfirmed?: true
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/** How long an unconfirmed delivery may stay unlanded in a live session before the ledger treats the
|
|
40
|
+
* paste as swallowed and redelivers it (once). Generous: a busy receiver records a queued message late. */
|
|
41
|
+
export const UNLANDED_AFTER_MS = 120_000
|
|
42
|
+
|
|
37
43
|
const inflightPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.inflight.jsonl`)
|
|
38
44
|
const orphansPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.orphans.jsonl`)
|
|
45
|
+
const interruptedPath = (stateDir: string, identity: string) => join(stateDir, `${identity}.interrupted.jsonl`)
|
|
39
46
|
|
|
40
47
|
function readJsonl(path: string): InflightEntry[] {
|
|
41
48
|
if (!existsSync(path)) return []
|
|
@@ -84,6 +91,11 @@ export interface InflightSweepDeps {
|
|
|
84
91
|
sessionToken: (identity: string) => string | null
|
|
85
92
|
/** Did the receiving session answer this entry? */
|
|
86
93
|
processed: (entry: InflightEntry) => boolean
|
|
94
|
+
/** Did the turn this entry started FINISH? Absent → processed counts as finished (legacy). */
|
|
95
|
+
completed?: (entry: InflightEntry) => boolean
|
|
96
|
+
/** Did the entry land in the session at all? Only consulted for `unconfirmed` entries. */
|
|
97
|
+
landed?: (entry: InflightEntry) => boolean
|
|
98
|
+
now?: number
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
/**
|
|
@@ -97,20 +109,57 @@ export function sweepInflight(stateDir: string, identity: string, deps: Inflight
|
|
|
97
109
|
const current = deps.sessionToken(identity)
|
|
98
110
|
const keep: InflightEntry[] = []
|
|
99
111
|
const orphaned: InflightEntry[] = []
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
const interrupted: InflightEntry[] = []
|
|
113
|
+
const safe = (f: ((e: InflightEntry) => boolean) | undefined, e: InflightEntry, dflt: boolean): boolean => {
|
|
114
|
+
if (!f) return dflt
|
|
102
115
|
try {
|
|
103
|
-
|
|
116
|
+
return f(e)
|
|
104
117
|
} catch {
|
|
105
|
-
|
|
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)
|
|
106
129
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
140
|
+
}
|
|
141
|
+
if (current !== null && current === e.sessionToken) {
|
|
142
|
+
// Unknown landing (confirm window ran out): once seen in the session it is a normal pending entry;
|
|
143
|
+
// still unseen after UNLANDED_AFTER_MS → the paste was swallowed → orphan (redeliver once).
|
|
144
|
+
if (e.unconfirmed && deps.landed) {
|
|
145
|
+
let landed = false
|
|
146
|
+
try {
|
|
147
|
+
landed = deps.landed(e)
|
|
148
|
+
} catch {
|
|
149
|
+
landed = false
|
|
150
|
+
}
|
|
151
|
+
if (landed) keep.push({ ...e, unconfirmed: undefined })
|
|
152
|
+
else if ((deps.now ?? Date.now()) - e.ts > UNLANDED_AFTER_MS) orphaned.push(e)
|
|
153
|
+
else keep.push(e)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
keep.push(e)
|
|
157
|
+
} else orphaned.push(e)
|
|
110
158
|
}
|
|
111
159
|
writeJsonl(inflightPath(stateDir, identity), keep)
|
|
112
160
|
if (orphaned.length) for (const o of orphaned) appendFileSync(orphansPath(stateDir, identity), JSON.stringify(o) + '\n', { mode: 0o600 })
|
|
113
|
-
|
|
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
|
|
114
163
|
}
|
|
115
164
|
|
|
116
165
|
export function listOrphanIdentities(stateDir: string): string[] {
|
|
@@ -131,8 +180,27 @@ export function takeOrphans(stateDir: string, identity: string): InflightEntry[]
|
|
|
131
180
|
return entries
|
|
132
181
|
}
|
|
133
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
|
+
|
|
134
201
|
/** Remove an identity's ledger + orphans (peer removal). */
|
|
135
202
|
export function purgeInflight(stateDir: string, identity: string): void {
|
|
136
203
|
rmSync(inflightPath(stateDir, identity), { force: true })
|
|
137
204
|
rmSync(orphansPath(stateDir, identity), { force: true })
|
|
205
|
+
rmSync(interruptedPath(stateDir, identity), { force: true })
|
|
138
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
|
@@ -294,6 +294,13 @@ export interface WarmDeliverSeam {
|
|
|
294
294
|
* the real `resubmitHosted` (a bare CR over the control channel). Tests inject a spy to assert the
|
|
295
295
|
* retry fires only while unconfirmed — and never once the record is in. */
|
|
296
296
|
resubmit?: (identity: string) => Promise<void>
|
|
297
|
+
/** UNKNOWN ≠ FAILED (incident 18.10: a busy receiver's transcript record landed after the grace,
|
|
298
|
+
* the sender got «NOT delivered», believed it and sent a duplicate). When set, a confirm grace that
|
|
299
|
+
* runs out on a LIVE session is reported as UNCONFIRMED — `onUnconfirmed()` fires and the delivery
|
|
300
|
+
* returns ok — and the inflight ledger, not the sender, owns any retry (docs/20 §Durable inbound).
|
|
301
|
+
* Unset (default): the strict legacy false-FAIL. */
|
|
302
|
+
allowUnconfirmed?: boolean
|
|
303
|
+
onUnconfirmed?: () => void
|
|
297
304
|
}
|
|
298
305
|
|
|
299
306
|
/**
|
|
@@ -445,6 +452,10 @@ async function deliverViaHost(
|
|
|
445
452
|
}
|
|
446
453
|
await sleep(LIVENESS_POLL_MS)
|
|
447
454
|
}
|
|
455
|
+
if (seam.allowUnconfirmed) {
|
|
456
|
+
seam.onUnconfirmed?.()
|
|
457
|
+
return ok(undefined)
|
|
458
|
+
}
|
|
448
459
|
return err(
|
|
449
460
|
`hosted peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
450
461
|
`(no transcript record carrying the message within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
@@ -892,6 +903,106 @@ export function deliveryProcessed(probe: ProcessedProbe, env: NodeJS.ProcessEnv
|
|
|
892
903
|
return false
|
|
893
904
|
}
|
|
894
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
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Did the envelope LAND in the receiving session at all (any record carrying it, including claude's
|
|
978
|
+
* queue-operation for a busy session)? Weaker than deliveryProcessed — it separates «queued / being
|
|
979
|
+
* worked on» from «the paste was swallowed and nothing will ever see it». Structural runtimes (opencode)
|
|
980
|
+
* always landed: their delivery ack is a native persistence ack.
|
|
981
|
+
*/
|
|
982
|
+
export function deliveryLanded(probe: ProcessedProbe, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
983
|
+
if (getAdapter(probe.runtime).deliveryConfirm === 'structural') return true
|
|
984
|
+
const needles = confirmNeedles(probe.rendered)
|
|
985
|
+
if (!needles.length || needles.every(n => !n)) return false
|
|
986
|
+
for (const path of compactCandidateFiles(probe.runtime, probe.cwd, env)) {
|
|
987
|
+
let st
|
|
988
|
+
try {
|
|
989
|
+
st = statSync(path)
|
|
990
|
+
} catch {
|
|
991
|
+
continue
|
|
992
|
+
}
|
|
993
|
+
if (st.mtimeMs < probe.ts - 5000) continue
|
|
994
|
+
for (const line of readFileTailFrom(path, Math.max(0, st.size - PROCESSED_TAIL_BYTES)).split(/\r?\n/)) {
|
|
995
|
+
if (!line.trim()) continue
|
|
996
|
+
try {
|
|
997
|
+
if (jsonStringValuesContainAll(JSON.parse(line), needles)) return true
|
|
998
|
+
} catch {
|
|
999
|
+
/* skip */
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return false
|
|
1004
|
+
}
|
|
1005
|
+
|
|
895
1006
|
/** One confirmed delivery to an AGENT endpoint, as transport reports it to the inflight ledger. */
|
|
896
1007
|
export interface InflightNote {
|
|
897
1008
|
sentAt: string
|
|
@@ -905,6 +1016,8 @@ export interface InflightNote {
|
|
|
905
1016
|
topic?: string
|
|
906
1017
|
attachments?: string[]
|
|
907
1018
|
rendered: string
|
|
1019
|
+
/** The live confirm window ran out (see WarmDeliverSeam.allowUnconfirmed). */
|
|
1020
|
+
unconfirmed?: true
|
|
908
1021
|
}
|
|
909
1022
|
|
|
910
1023
|
function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
@@ -1144,6 +1257,10 @@ export interface RouteResult {
|
|
|
1144
1257
|
queuedBy?: 'ephemeral' | 'composer'
|
|
1145
1258
|
/** Queue depth right after the enqueue (observability; only with queued). */
|
|
1146
1259
|
queueDepth?: number
|
|
1260
|
+
/** The bytes reached the live session but its acceptance record did not appear within the confirm
|
|
1261
|
+
* window (e.g. a busy receiver). NOT a failure: do not resend — the core's inflight ledger checks
|
|
1262
|
+
* it landed and redelivers once (or tells you) if it did not. */
|
|
1263
|
+
unconfirmed?: true
|
|
1147
1264
|
}
|
|
1148
1265
|
|
|
1149
1266
|
// WakeFn — the lifecycle wake primitive, INJECTED (transport never imports
|
|
@@ -1293,7 +1410,7 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
|
|
|
1293
1410
|
}
|
|
1294
1411
|
}
|
|
1295
1412
|
|
|
1296
|
-
type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string }
|
|
1413
|
+
type InflightInfo = { sentAt: string; caller: ResolvedCaller; message: string; topic?: string; attachments?: string[]; envelope: string; unconfirmed?: boolean }
|
|
1297
1414
|
|
|
1298
1415
|
/** Best-effort inflight record (OC07) — only for agent (tui) endpoints. */
|
|
1299
1416
|
function noteInflight(
|
|
@@ -1318,6 +1435,7 @@ function noteInflight(
|
|
|
1318
1435
|
...(info.topic ? { topic: info.topic } : {}),
|
|
1319
1436
|
...(info.attachments?.length ? { attachments: info.attachments } : {}),
|
|
1320
1437
|
rendered: renderEnvelopeForAgent(info.envelope),
|
|
1438
|
+
...(info.unconfirmed ? { unconfirmed: true as const } : {}),
|
|
1321
1439
|
})
|
|
1322
1440
|
} catch {
|
|
1323
1441
|
/* a post-delivery hook must never fail the delivery */
|
|
@@ -1790,16 +1908,26 @@ async function routeSendInner(
|
|
|
1790
1908
|
// peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
|
|
1791
1909
|
// deliverWarm is HOST-AWARE (spawn-flip Ф0b-2): a supervisor-hosted target delivers over its
|
|
1792
1910
|
// socket, any other target keeps the tmux path. Flag-off → byte-identical to deliverViaTmux.
|
|
1793
|
-
|
|
1911
|
+
// Unknown ≠ failed: a confirm window that runs out on a LIVE session is reported unconfirmed and the
|
|
1912
|
+
// inflight ledger owns the retry — only when that ledger is wired (deps.noteInflight), else strict.
|
|
1913
|
+
let unconfirmed = false
|
|
1914
|
+
const delivered = await deliverWarm(
|
|
1915
|
+
target.value,
|
|
1916
|
+
envelope,
|
|
1917
|
+
peer.cwd,
|
|
1918
|
+
deps.noteInflight ? { allowUnconfirmed: true, onUnconfirmed: () => (unconfirmed = true) } : {},
|
|
1919
|
+
env,
|
|
1920
|
+
)
|
|
1794
1921
|
if (!delivered.ok) return delivered
|
|
1795
1922
|
noteTopic(deps, target.value.address, topic)
|
|
1796
1923
|
noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
|
|
1797
|
-
noteInflight(deps, target.value, peer.cwd, inflightInfo, env)
|
|
1924
|
+
noteInflight(deps, target.value, peer.cwd, { ...inflightInfo, unconfirmed }, env)
|
|
1798
1925
|
return ok({
|
|
1799
1926
|
ok: true,
|
|
1800
1927
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|
|
1801
1928
|
woke: false,
|
|
1802
1929
|
ts: sentAt,
|
|
1930
|
+
...(unconfirmed ? { unconfirmed: true as const } : {}),
|
|
1803
1931
|
})
|
|
1804
1932
|
}
|
|
1805
1933
|
|