omo-ai 5.0.1 → 5.1.1
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/bin/lib/category-coverage.js +5 -1
- package/bin/lib/computer-use-doctor.js +90 -0
- package/bin/lib/daemon-args.js +54 -0
- package/bin/lib/daemon-doctor-report.js +30 -0
- package/bin/lib/daemon-operations.js +190 -0
- package/bin/lib/daemon-rollback.js +158 -0
- package/bin/lib/daemon-status.js +128 -0
- package/bin/lib/daemon.js +109 -66
- package/bin/lib/doctor-migration.js +69 -15
- package/bin/lib/doctor.js +2 -2
- package/bin/lib/launcher.js +60 -4
- package/bin/lib/package-paths.js +17 -5
- package/bin/lib/provider-map.json +1 -1
- package/bin/lib/standalone-binary.js +67 -0
- package/package.json +2 -2
- package/plugin/CHANGELOG.md +137 -0
- package/plugin/extensions/assets.generated.json +7 -0
- package/plugin/extensions/memory-run-supervisor.mjs +2 -2
- package/plugin/extensions/omo-computer-use.js +2 -0
- package/plugin/extensions/omo-init-deep-advisor.js +2 -2
- package/plugin/extensions/omo-member.js +2 -2
- package/plugin/extensions/omo-task.js +2 -2
- package/plugin/extensions/omo.js +2 -2
- package/plugin/package.json +2 -1
- package/plugin/runtime/agent-toolkit-sdk/sdk.js +2 -2
- package/plugin/runtime/ast-grep-mcp/manifest.json +1 -1
- package/plugin/runtime/category-coverage/index.js +10 -4
- package/plugin/runtime/lsp-daemon/dist/.omo-runtime-manifest.json +1 -1
- package/plugin/runtime/rollback-migrate.js +2 -0
- package/plugin/scripts/install.mjs +2 -0
- package/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
- package/plugin/skills/coding-agent-sessions/SKILL.md +1 -1
- package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/aside_scanner.py +3 -0
- package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/cli.py +4 -0
- package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/file_scanners.py +6 -2
- package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/scanners.py +7 -1
- package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/transcript.py +3 -1
- package/plugin/skills/coding-agent-sessions/scripts/agent_sessions/types.py +3 -1
- package/plugin/skills/coding-agent-sessions/scripts/find-agent-sessions.py +4 -0
|
@@ -97,7 +97,7 @@ export async function engineAvailableModels(input) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* @typedef {{ name: string, providers: string[] }} UnusableCategory
|
|
100
|
+
* @typedef {{ name: string, providers: string[], unlistedProviderModel?: string }} UnusableCategory
|
|
101
101
|
* @typedef {{ usable: string[], unusable: UnusableCategory[] }} CategoryCoverage
|
|
102
102
|
* @typedef {{
|
|
103
103
|
* models: EngineModel[],
|
|
@@ -120,6 +120,10 @@ function total(coverage) {
|
|
|
120
120
|
|
|
121
121
|
// The category-unavailable notice's wording (omo-senpi components/task/category-unavailable-warning.ts).
|
|
122
122
|
function gapLine(gap) {
|
|
123
|
+
if (gap.unlistedProviderModel !== undefined) {
|
|
124
|
+
const gateway = gap.unlistedProviderModel.slice(0, gap.unlistedProviderModel.indexOf("/"))
|
|
125
|
+
return `Category "${gap.name}" is unavailable: only ${gateway}, which its chain does not list, serves its models, and omo never bills a builtin category to an unlisted provider. Connect one of its providers (${gap.providers.join(", ")}) with /login <provider>, or opt in with categories.${gap.name}.model = "${gap.unlistedProviderModel}" in omo.json.`
|
|
126
|
+
}
|
|
123
127
|
if (gap.providers.length === 0) {
|
|
124
128
|
return `Category "${gap.name}" has no usable model: your connected providers serve none of its fallback-chain models. Pin categories.${gap.name}.model in omo.json.`
|
|
125
129
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { pathToFileURL } from "node:url"
|
|
3
|
+
import { packageManifest, packageRoot } from "./package-paths.js"
|
|
4
|
+
|
|
5
|
+
export const COMPUTER_USE_RUNTIME = join("plugin", "runtime", "category-coverage", "index.js")
|
|
6
|
+
|
|
7
|
+
async function loadRuntime() {
|
|
8
|
+
return import(pathToFileURL(join(packageRoot, COMPUTER_USE_RUNTIME)).href)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function baseLine(report) {
|
|
12
|
+
return `INFO computer use: enabled=${report.enabled} supported=${report.supported} host=${report.host}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readyLines(report) {
|
|
16
|
+
const capabilities = report.capabilities
|
|
17
|
+
const displayServer = capabilities.displayServer ? ` (${capabilities.displayServer})` : ""
|
|
18
|
+
const backendLevel = capabilities.backend === "unavailable" ? "WARN" : "PASS"
|
|
19
|
+
const permissionLevel = capabilities.capture && capabilities.input && capabilities.ax ? "PASS" : "WARN"
|
|
20
|
+
const displayLevel = capabilities.displayCount > 0 && !capabilities.screenLocked ? "PASS" : "WARN"
|
|
21
|
+
const stopLevel = capabilities.stopPath === "none" ? "WARN" : "PASS"
|
|
22
|
+
const stopReason = capabilities.stopReason ? ` reason=${capabilities.stopReason}` : ""
|
|
23
|
+
// The engine arms the stop chord only when a session starts input (`stopPath.start`), so a pre-session
|
|
24
|
+
// probe always sees `none` / `no-global-listener`; that is the normal idle state, not a failure.
|
|
25
|
+
const stopLine =
|
|
26
|
+
capabilities.stopPath === "none" && capabilities.stopReason === "no-global-listener"
|
|
27
|
+
? "INFO computer use stop path: not armed until computer use starts input (checked when a session activates)"
|
|
28
|
+
: `${stopLevel} computer use stop path: ${capabilities.stopPath}${stopReason}`
|
|
29
|
+
return [
|
|
30
|
+
baseLine(report),
|
|
31
|
+
`PASS computer use engine: ${report.enginePath} (version ${report.hello.engineVersion}, ABI ${report.hello.abi}, protocol ${report.hello.protocolVersion})`,
|
|
32
|
+
`${backendLevel} computer use backend: ${capabilities.backend}${displayServer}`,
|
|
33
|
+
`${permissionLevel} computer use permissions: capture=${capabilities.capturePermission} input=${capabilities.inputPermission} accessibility=${capabilities.axPermission}`,
|
|
34
|
+
`${displayLevel} computer use display: count=${capabilities.displayCount} screenLocked=${capabilities.screenLocked}`,
|
|
35
|
+
stopLine,
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatComputerUseDoctorLines(report) {
|
|
40
|
+
switch (report.kind) {
|
|
41
|
+
case "ready":
|
|
42
|
+
return readyLines(report)
|
|
43
|
+
case "skipped":
|
|
44
|
+
return [
|
|
45
|
+
baseLine(report),
|
|
46
|
+
report.reason === "disabled"
|
|
47
|
+
? "INFO computer use probe: skipped because computer.enabled=false"
|
|
48
|
+
: `INFO computer use probe: skipped because ${report.host} is unsupported`,
|
|
49
|
+
]
|
|
50
|
+
case "unavailable":
|
|
51
|
+
return [
|
|
52
|
+
baseLine(report),
|
|
53
|
+
`FAIL computer use engine: ${report.diagnostic.code}: ${report.diagnostic.message} ${report.diagnostic.cause}`.trimEnd(),
|
|
54
|
+
`INFO computer use engine paths tried: ${report.diagnostic.attemptedPaths.join(", ") || "<none>"}`,
|
|
55
|
+
]
|
|
56
|
+
case "not-installed":
|
|
57
|
+
return [
|
|
58
|
+
baseLine(report),
|
|
59
|
+
"INFO computer use engine: not installed yet; it is downloaded the first time computer use starts",
|
|
60
|
+
`INFO computer use engine paths checked: ${report.attemptedPaths.join(", ") || "<none>"}`,
|
|
61
|
+
]
|
|
62
|
+
case "failed":
|
|
63
|
+
return [
|
|
64
|
+
baseLine(report),
|
|
65
|
+
`FAIL computer use engine: ${report.code}: ${report.message}`,
|
|
66
|
+
`INFO computer use engine path: ${report.enginePath}`,
|
|
67
|
+
]
|
|
68
|
+
default:
|
|
69
|
+
throw new TypeError(`unknown computer use doctor report kind: ${String(report.kind)}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function doctorComputerUseLines(options = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const runtime = await (options.loadRuntime ?? loadRuntime)()
|
|
76
|
+
const report = await runtime.computerUseDoctorReport({
|
|
77
|
+
cwd: options.cwd ?? process.cwd(),
|
|
78
|
+
env: options.env ?? process.env,
|
|
79
|
+
version: packageManifest().version,
|
|
80
|
+
packageRoot,
|
|
81
|
+
platform: options.platform ?? process.platform,
|
|
82
|
+
arch: options.arch ?? process.arch,
|
|
83
|
+
timeoutMs: options.timeoutMs,
|
|
84
|
+
})
|
|
85
|
+
return formatComputerUseDoctorLines(report)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
88
|
+
return [`WARN computer use: diagnostics unavailable: ${message}`]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `omo daemon` command-line contract: the flags this wrapper consumes, its exit codes, and the
|
|
3
|
+
* small argv helpers `runDaemonCommand` uses. Moved verbatim out of `daemon.js`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Flags this wrapper consumes itself; anything else after `attach` belongs to the launch. */
|
|
7
|
+
const DAEMON_FLAGS = new Set([
|
|
8
|
+
"--json",
|
|
9
|
+
"--no-upgrade",
|
|
10
|
+
"--persistent",
|
|
11
|
+
"--foreground",
|
|
12
|
+
"--include-workers",
|
|
13
|
+
"--drain",
|
|
14
|
+
"--all",
|
|
15
|
+
"--wait",
|
|
16
|
+
"--dry-run",
|
|
17
|
+
"--allow-missing-index",
|
|
18
|
+
"--prune-store-index",
|
|
19
|
+
])
|
|
20
|
+
const DAEMON_VALUE_FLAGS = new Set(["--store", "--timeout"])
|
|
21
|
+
|
|
22
|
+
/** A named code per outcome, so a script never has to parse the message to know what happened. */
|
|
23
|
+
export const DAEMON_EXIT = {
|
|
24
|
+
ok: 0,
|
|
25
|
+
usage: 2,
|
|
26
|
+
notRunning: 3,
|
|
27
|
+
unsupported: 4,
|
|
28
|
+
engineRefused: 5,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readTimeoutSeconds(args) {
|
|
32
|
+
const index = args.indexOf("--timeout")
|
|
33
|
+
if (index === -1) return 600
|
|
34
|
+
const value = Number(args[index + 1])
|
|
35
|
+
return Number.isFinite(value) && value >= 0 ? value : 600
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function attachLaunchArgs(args) {
|
|
39
|
+
const launchArgs = []
|
|
40
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
41
|
+
const argument = args[index]
|
|
42
|
+
if (DAEMON_FLAGS.has(argument)) continue
|
|
43
|
+
if (DAEMON_VALUE_FLAGS.has(argument)) {
|
|
44
|
+
index += 1
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
launchArgs.push(argument)
|
|
48
|
+
}
|
|
49
|
+
return launchArgs
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function blockingPause(milliseconds) {
|
|
53
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
|
|
54
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { DAEMON_EXIT } from "./daemon-args.js"
|
|
2
|
+
import { parseEngineLine, readAllEndpoints } from "./daemon-operations.js"
|
|
3
|
+
import { formatDoctorLines } from "./daemon-status.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `omo doctor` view: one INFO line, never a FAIL - a machine without a daemon is healthy,
|
|
7
|
+
* it just has nothing shared to report. Returned as lines so doctor can place it with the rest.
|
|
8
|
+
*/
|
|
9
|
+
export function daemonReportLines({ engine, pluginRoot, agentDir, env, platform }) {
|
|
10
|
+
if (platform === "win32") return ["INFO Daemon: unavailable on win32 (no unix socket to share)"]
|
|
11
|
+
const all = readAllEndpoints(engine, agentDir, env)
|
|
12
|
+
if (all.kind === "all") {
|
|
13
|
+
if (all.endpoints.length === 0) return ["INFO Daemon: not running"]
|
|
14
|
+
return formatDoctorLines(all.endpoints)
|
|
15
|
+
}
|
|
16
|
+
const result = engine.run(["host", "status", "--json"], { env: { ...env, OMO_AGENT_DIR: agentDir } })
|
|
17
|
+
const parsed = parseEngineLine(result.stdout ?? "")
|
|
18
|
+
if (result.exitCode !== DAEMON_EXIT.ok || parsed === undefined) {
|
|
19
|
+
return ["INFO Daemon: not running", "INFO Hosts: engine too old to enumerate"]
|
|
20
|
+
}
|
|
21
|
+
const sessions = parsed.sessions?.total ?? parsed.sessions?.length ?? 0
|
|
22
|
+
const parts = [
|
|
23
|
+
`pid ${parsed.pid}`,
|
|
24
|
+
parsed.instanceId === undefined ? undefined : `instance ${parsed.instanceId}`,
|
|
25
|
+
parsed.engineVersion === undefined ? undefined : `engine ${parsed.engineVersion}`,
|
|
26
|
+
`${sessions} session(s)`,
|
|
27
|
+
`zombies ${parsed.zombies ?? 0}`,
|
|
28
|
+
].filter(Boolean)
|
|
29
|
+
return [`INFO Daemon: running ${parts.join(" · ")}`, "INFO Hosts: engine too old to enumerate"]
|
|
30
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "node:fs"
|
|
2
|
+
import { basename, join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
import { decorateEndpoints, formatEndpointLines, statusPayload } from "./daemon-status.js"
|
|
5
|
+
|
|
6
|
+
export function parseEngineLine(stdout) {
|
|
7
|
+
const line = stdout.trim().split("\n").filter(Boolean).pop()
|
|
8
|
+
if (line === undefined) return undefined
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(line)
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readAllEndpoints(engine, agentDir, env) {
|
|
17
|
+
const result = engine.run(["host", "status", "--json", "--all", "--include-workers"], {
|
|
18
|
+
env: { ...env, OMO_AGENT_DIR: agentDir },
|
|
19
|
+
})
|
|
20
|
+
const parsed = parseEngineLine(result.stdout ?? "")
|
|
21
|
+
if (result.exitCode === 2 || !Array.isArray(parsed?.endpoints)) return { kind: "legacy", result }
|
|
22
|
+
const endpoints = decorateEndpoints(parsed.endpoints, agentDir)
|
|
23
|
+
return { kind: "all", result, endpoints }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function runStatus({ engine, agentDir, env, json, stdout, stderr, _test }) {
|
|
27
|
+
const all = readAllEndpoints(engine, agentDir, env)
|
|
28
|
+
if (all.kind === "legacy") return { legacy: true, result: all.result }
|
|
29
|
+
if (all.result.stderr) stderr.write(all.result.stderr)
|
|
30
|
+
_test?.afterRead?.({ agentDir, endpoints: all.endpoints })
|
|
31
|
+
if (all.endpoints.length === 0 && all.result.exitCode !== 0) {
|
|
32
|
+
if (json) stdout.write(`${JSON.stringify(statusPayload([]))}\n`)
|
|
33
|
+
else stdout.write("daemon: not running\n")
|
|
34
|
+
return { legacy: false, exitCode: all.result.exitCode, endpoints: [] }
|
|
35
|
+
}
|
|
36
|
+
if (json) stdout.write(`${JSON.stringify(statusPayload(all.endpoints))}\n`)
|
|
37
|
+
else stdout.write(`${formatEndpointLines(all.endpoints).join("\n")}\n`)
|
|
38
|
+
return { legacy: false, exitCode: all.result.exitCode, endpoints: all.endpoints }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function liveEndpoints(endpoints) {
|
|
42
|
+
return endpoints.filter((endpoint) => endpoint.reachable && typeof endpoint.socket === "string")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ownedEndpoints(endpoints) {
|
|
46
|
+
return endpoints.filter((endpoint) => {
|
|
47
|
+
if (typeof endpoint.socket !== "string") return false
|
|
48
|
+
return endpoint.reachable ||
|
|
49
|
+
(endpoint.generations ?? []).some((generation) => generation.alive) ||
|
|
50
|
+
(endpoint.claims_live ?? 0) > 0
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function endpointCall(engine, args, agentDir, env) {
|
|
55
|
+
const result = engine.run(args, { env: { ...env, OMO_AGENT_DIR: agentDir } })
|
|
56
|
+
return { result, parsed: parseEngineLine(result.stdout ?? "") }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function runGc({ engine, migration, agentDir, env, json, pruneStoreIndex, stdout, stderr }) {
|
|
60
|
+
const { result, parsed } = endpointCall(engine, ["host", "gc", "--json"], agentDir, env)
|
|
61
|
+
if (result.stderr) stderr.write(result.stderr)
|
|
62
|
+
const removed = Array.isArray(parsed?.removed) ? parsed.removed : []
|
|
63
|
+
const kept = Array.isArray(parsed?.kept) ? parsed.kept : []
|
|
64
|
+
const reapedMeta = []
|
|
65
|
+
for (const entry of removed) {
|
|
66
|
+
if (typeof entry.socket !== "string") continue
|
|
67
|
+
const meta = entry.socket.replace(/\.sock$/, ".meta.json")
|
|
68
|
+
if (!existsSync(meta)) continue
|
|
69
|
+
rmSync(meta, { force: true })
|
|
70
|
+
reapedMeta.push(meta)
|
|
71
|
+
}
|
|
72
|
+
const prunedStores = pruneStoreIndex
|
|
73
|
+
? migration.run({ operation: "prune-store-index", indexPath: join(agentDir, "rpc", "task-stores.json") }).removed
|
|
74
|
+
: []
|
|
75
|
+
const payload = { removed, kept, reaped_meta: reapedMeta, pruned_stores: prunedStores }
|
|
76
|
+
if (json) stdout.write(`${JSON.stringify(payload)}\n`)
|
|
77
|
+
else stdout.write(`reaped: ${removed.length}\n`)
|
|
78
|
+
return result.exitCode
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function runHandoff({ engine, pluginRoot, agentDir, env, policy, config, stdout, stderr }) {
|
|
82
|
+
const all = readAllEndpoints(engine, agentDir, env)
|
|
83
|
+
if (all.kind === "legacy") {
|
|
84
|
+
const call = endpointCall(
|
|
85
|
+
engine,
|
|
86
|
+
buildEnsureArgs("handoff", undefined, pluginRoot, policy),
|
|
87
|
+
agentDir,
|
|
88
|
+
hostCommandEnvironment(env, agentDir, config),
|
|
89
|
+
)
|
|
90
|
+
if (call.result.stderr) stderr.write(call.result.stderr)
|
|
91
|
+
stdout.write(`daemon: ${call.parsed?.action ?? "handoff"}\n`)
|
|
92
|
+
return call.result.exitCode
|
|
93
|
+
}
|
|
94
|
+
let refused = false
|
|
95
|
+
const commandEnv = hostCommandEnvironment(env, agentDir, config)
|
|
96
|
+
for (const endpoint of liveEndpoints(all.endpoints)) {
|
|
97
|
+
const daemon = basename(endpoint.socket) === "rpc.sock"
|
|
98
|
+
const args = daemon
|
|
99
|
+
? buildEnsureArgs("handoff", undefined, pluginRoot, policy)
|
|
100
|
+
: buildEnsureArgs("ensure", endpoint.socket, pluginRoot, policy)
|
|
101
|
+
const call = endpointCall(engine, args, agentDir, commandEnv)
|
|
102
|
+
if (call.result.stderr) stderr.write(call.result.stderr)
|
|
103
|
+
if (call.result.exitCode !== 0 || call.parsed?.action === "refuse") refused = true
|
|
104
|
+
stdout.write(`${endpoint.socket}: ${call.parsed?.action ?? "refuse"}\n`)
|
|
105
|
+
}
|
|
106
|
+
return refused ? 3 : 0
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function buildEnsureArgs(command, socket, pluginRoot, policy) {
|
|
110
|
+
const args = ["host", command, "--json"]
|
|
111
|
+
if (socket !== undefined) args.push("--socket", socket)
|
|
112
|
+
args.push("--launch-spec", join(pluginRoot, "daemon-launch-spec.json"), "--policy", policy)
|
|
113
|
+
return args
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function hostCommandEnvironment(env, agentDir, config) {
|
|
117
|
+
const result = { ...env, OMO_AGENT_DIR: agentDir }
|
|
118
|
+
const idleExitMs = config?.task?.host_idle_exit_ms
|
|
119
|
+
if (typeof idleExitMs === "number" && Number.isFinite(idleExitMs) && idleExitMs > 0) {
|
|
120
|
+
result.SENPI_RPC_HOST_IDLE_EXIT_MS = String(Math.trunc(idleExitMs))
|
|
121
|
+
}
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function runStopAll({ engine, agentDir, env, drain, wait, timeoutSeconds, stdout, stderr, now, pause }) {
|
|
126
|
+
const all = readAllEndpoints(engine, agentDir, env)
|
|
127
|
+
if (all.kind === "legacy") return undefined
|
|
128
|
+
let failed = false
|
|
129
|
+
for (const endpoint of ownedEndpoints(all.endpoints)) {
|
|
130
|
+
const args = ["host", "stop", "--json", "--socket", endpoint.socket]
|
|
131
|
+
if (drain) args.push("--drain")
|
|
132
|
+
const call = endpointCall(engine, args, agentDir, env)
|
|
133
|
+
if (call.result.stderr) stderr.write(call.result.stderr)
|
|
134
|
+
if (!wait) {
|
|
135
|
+
if (call.result.exitCode !== 0) failed = true
|
|
136
|
+
stdout.write(`${endpoint.socket}: requested (not awaited)\n`)
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
const action = call.parsed?.action
|
|
140
|
+
const refusal = action === "refuse" ? call.parsed?.reason : action
|
|
141
|
+
const liveAtRequest = (endpoint.generations ?? []).some((generation) => generation.alive)
|
|
142
|
+
const claimsAtRequest = (endpoint.claims_live ?? 0) > 0
|
|
143
|
+
const ownedAtRequest = liveAtRequest || claimsAtRequest
|
|
144
|
+
if (
|
|
145
|
+
call.result.exitCode !== 0 &&
|
|
146
|
+
!(ownedAtRequest && (refusal === "drain_unsupported" || refusal === "unknown_owner"))
|
|
147
|
+
) {
|
|
148
|
+
if (!ownedAtRequest) {
|
|
149
|
+
stdout.write(`${endpoint.socket}: already drained\n`)
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
failed = true
|
|
153
|
+
stdout.write(`${endpoint.socket}: refused\n`)
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
const result = waitForEndpoint(engine, endpoint.socket, agentDir, env, timeoutSeconds, now, pause)
|
|
157
|
+
if (result.done) stdout.write(`${endpoint.socket}: drained (${result.polls} polls)\n`)
|
|
158
|
+
else {
|
|
159
|
+
failed = true
|
|
160
|
+
stdout.write(`${endpoint.socket}: TIMEOUT: still live (${result.summary})\n`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return failed ? 3 : 0
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function waitForEndpoint(engine, socket, agentDir, env, timeoutSeconds, now, pause) {
|
|
167
|
+
const deadline = now() + timeoutSeconds * 1_000
|
|
168
|
+
let polls = 0
|
|
169
|
+
let latest
|
|
170
|
+
while (now() <= deadline) {
|
|
171
|
+
const call = endpointCall(engine, ["host", "status", "--json", "--socket", socket, "--include-workers"], agentDir, env)
|
|
172
|
+
latest = call.parsed
|
|
173
|
+
polls += 1
|
|
174
|
+
const alive = (latest?.generations ?? []).some((generation) => generation.alive)
|
|
175
|
+
if (!alive && (latest?.claims_live ?? 0) === 0) return { done: true, polls }
|
|
176
|
+
pause(250)
|
|
177
|
+
}
|
|
178
|
+
const pid = latest?.pid ?? "?"
|
|
179
|
+
const sessions = latest?.sessions?.total ?? 0
|
|
180
|
+
const claims = latest?.claims_live ?? 0
|
|
181
|
+
return { done: false, polls, summary: `pid ${pid}, ${sessions} sessions, ${claims} claims` }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function parseStoreArgs(args) {
|
|
185
|
+
const stores = []
|
|
186
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
187
|
+
if (args[index] === "--store" && args[index + 1] !== undefined) stores.push(resolve(args[index + 1]))
|
|
188
|
+
}
|
|
189
|
+
return stores
|
|
190
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
3
|
+
import { basename, join, resolve } from "node:path"
|
|
4
|
+
|
|
5
|
+
import { parseEngineLine, readAllEndpoints } from "./daemon-operations.js"
|
|
6
|
+
|
|
7
|
+
function readJson(path) {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sidecarRoots(agentDir) {
|
|
16
|
+
const alt = createHash("sha256").update(agentDir, "utf8").digest("hex").slice(0, 8)
|
|
17
|
+
return [join(agentDir, "rpc", "shards"), `/tmp/omo-rpc-${alt}`]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sidecars(agentDir) {
|
|
21
|
+
const rows = []
|
|
22
|
+
for (const root of sidecarRoots(agentDir)) {
|
|
23
|
+
let names
|
|
24
|
+
try {
|
|
25
|
+
names = readdirSync(root)
|
|
26
|
+
} catch {
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
for (const name of names.filter((entry) => entry.endsWith(".meta.json")).toSorted()) {
|
|
30
|
+
const path = join(root, name)
|
|
31
|
+
const document = readJson(path)
|
|
32
|
+
if (document !== undefined) rows.push({ path, document })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return rows
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function indexStores(agentDir) {
|
|
39
|
+
const path = join(agentDir, "rpc", "task-stores.json")
|
|
40
|
+
if (!existsSync(path)) return { present: false, stores: [] }
|
|
41
|
+
const document = readJson(path)
|
|
42
|
+
if (document?.version !== 1 || document.stores === null || typeof document.stores !== "object") {
|
|
43
|
+
return { present: false, stores: [] }
|
|
44
|
+
}
|
|
45
|
+
return { present: true, stores: Object.keys(document.stores).map((store) => resolve(store)) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collectStores(agentDir, explicitStores) {
|
|
49
|
+
const index = indexStores(agentDir)
|
|
50
|
+
const metadata = sidecars(agentDir)
|
|
51
|
+
const fromSidecars = metadata.flatMap(({ document }) => Array.isArray(document.stores) ? document.stores : [])
|
|
52
|
+
const stores = [
|
|
53
|
+
...new Set([...index.stores, ...fromSidecars, ...explicitStores].map((store) => resolve(store))),
|
|
54
|
+
].toSorted()
|
|
55
|
+
return {
|
|
56
|
+
index,
|
|
57
|
+
metadata,
|
|
58
|
+
fromSidecars: [...new Set(fromSidecars.map((store) => resolve(store)))],
|
|
59
|
+
stores,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function statusForSocket(engine, socket, agentDir, env) {
|
|
64
|
+
const result = engine.run(
|
|
65
|
+
["host", "status", "--json", "--socket", socket, "--include-workers"],
|
|
66
|
+
{ env: { ...env, OMO_AGENT_DIR: agentDir } },
|
|
67
|
+
)
|
|
68
|
+
const parsed = parseEngineLine(result.stdout ?? "")
|
|
69
|
+
if (
|
|
70
|
+
parsed === undefined ||
|
|
71
|
+
!Array.isArray(parsed.generations) ||
|
|
72
|
+
typeof parsed.claims_live !== "number"
|
|
73
|
+
) {
|
|
74
|
+
return { readable: false }
|
|
75
|
+
}
|
|
76
|
+
return { readable: true, report: parsed }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function runRollbackPrepare({
|
|
80
|
+
engine,
|
|
81
|
+
migration,
|
|
82
|
+
agentDir,
|
|
83
|
+
env,
|
|
84
|
+
explicitStores,
|
|
85
|
+
allowMissingIndex,
|
|
86
|
+
dryRun,
|
|
87
|
+
json,
|
|
88
|
+
stdout,
|
|
89
|
+
stderr,
|
|
90
|
+
}) {
|
|
91
|
+
const discovered = collectStores(agentDir, explicitStores)
|
|
92
|
+
const endpointSweep = readAllEndpoints(engine, agentDir, env)
|
|
93
|
+
const endpointEvidence = endpointSweep.kind === "all" && endpointSweep.endpoints.some((endpoint) => {
|
|
94
|
+
if (typeof endpoint.socket !== "string") return true
|
|
95
|
+
return basename(endpoint.socket) !== "rpc.sock"
|
|
96
|
+
})
|
|
97
|
+
if (
|
|
98
|
+
!discovered.index.present &&
|
|
99
|
+
(discovered.metadata.length > 0 || endpointEvidence) &&
|
|
100
|
+
!allowMissingIndex &&
|
|
101
|
+
explicitStores.length === 0
|
|
102
|
+
) {
|
|
103
|
+
stderr.write("store index missing: pass --store <dir> for every project that ran task children, or --allow-missing-index\n")
|
|
104
|
+
return 3
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const missing = discovered.stores.filter((store) => !existsSync(store))
|
|
108
|
+
const present = discovered.stores.filter((store) => existsSync(store))
|
|
109
|
+
const plans = present.map((storeDir) => migration.run({ storeDir, to: join(agentDir, "rpc", "rpc.sock"), planOnly: true }))
|
|
110
|
+
const sockets = [...new Set(plans.flatMap((plan) => plan.sockets))].toSorted()
|
|
111
|
+
const deadEndpoints = []
|
|
112
|
+
for (const socket of sockets) {
|
|
113
|
+
const statusResult = statusForSocket(engine, socket, agentDir, env)
|
|
114
|
+
if (!statusResult.readable) {
|
|
115
|
+
stderr.write(`rollback refused: endpoint status unreadable ${socket}\n`)
|
|
116
|
+
return 3
|
|
117
|
+
}
|
|
118
|
+
const status = statusResult.report
|
|
119
|
+
const alive = (status?.generations ?? []).some((generation) => generation.alive)
|
|
120
|
+
if (alive || (status?.claims_live ?? 0) > 0) {
|
|
121
|
+
stderr.write(`rollback refused: endpoint still live ${socket}\n`)
|
|
122
|
+
return 3
|
|
123
|
+
}
|
|
124
|
+
deadEndpoints.push(socket)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const results = present.map((storeDir) => migration.run({
|
|
128
|
+
storeDir,
|
|
129
|
+
to: join(agentDir, "rpc", "rpc.sock"),
|
|
130
|
+
deadEndpoints,
|
|
131
|
+
dryRun,
|
|
132
|
+
}))
|
|
133
|
+
const storesWithoutMap = discovered.metadata
|
|
134
|
+
.filter(({ document }) => !Array.isArray(document.stores))
|
|
135
|
+
.map(({ document, path }) => document.socket ?? basename(path))
|
|
136
|
+
const payload = {
|
|
137
|
+
dry_run: dryRun,
|
|
138
|
+
stores: results,
|
|
139
|
+
coverage: {
|
|
140
|
+
discovered: discovered.stores.length,
|
|
141
|
+
from_index: discovered.index.stores.length,
|
|
142
|
+
from_sidecars: discovered.fromSidecars.length,
|
|
143
|
+
from_store: explicitStores.length,
|
|
144
|
+
missing,
|
|
145
|
+
endpoints_without_store_map: storesWithoutMap,
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
if (json) stdout.write(`${JSON.stringify(payload)}\n`)
|
|
149
|
+
else {
|
|
150
|
+
for (const result of results) {
|
|
151
|
+
stdout.write(`${result.store_dir}: migrated: ${dryRun ? result.migrate : result.migrated}, skipped: ${result.skipped}\n`)
|
|
152
|
+
}
|
|
153
|
+
stdout.write(
|
|
154
|
+
`stores: ${payload.coverage.discovered} discovered (${payload.coverage.from_sidecars} from sidecars, ${payload.coverage.from_store} from --store), ${missing.length} missing, endpoints without a store map: ${storesWithoutMap.join(", ") || "none"}\n`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
return 0
|
|
158
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { basename, dirname, join } from "node:path"
|
|
3
|
+
|
|
4
|
+
function readJson(path) {
|
|
5
|
+
if (!existsSync(path)) return undefined
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(readFileSync(path, "utf8"))
|
|
8
|
+
} catch {
|
|
9
|
+
return undefined
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sidecarPath(endpoint, agentDir) {
|
|
14
|
+
if (endpoint.shard === null || endpoint.shard === undefined) return undefined
|
|
15
|
+
if (typeof endpoint.socket === "string") {
|
|
16
|
+
return join(dirname(endpoint.socket), `${endpoint.shard.kind}-${endpoint.shard.key}.meta.json`)
|
|
17
|
+
}
|
|
18
|
+
return join(agentDir, "rpc", "shards", `${endpoint.shard.kind}-${endpoint.shard.key}.meta.json`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function decorateEndpoints(endpoints, agentDir) {
|
|
22
|
+
return endpoints.map((endpoint) => {
|
|
23
|
+
const path = sidecarPath(endpoint, agentDir)
|
|
24
|
+
const owner = path === undefined ? undefined : readJson(path)
|
|
25
|
+
return owner === undefined ? endpoint : { ...endpoint, owner }
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function aggregateEndpoints(endpoints) {
|
|
30
|
+
const live = endpoints.filter((endpoint) => endpoint.reachable)
|
|
31
|
+
return {
|
|
32
|
+
live: live.length,
|
|
33
|
+
shards: live.filter((endpoint) => endpoint.shard?.kind === "p").length,
|
|
34
|
+
threads: live.filter((endpoint) => endpoint.shard?.kind === "i").length,
|
|
35
|
+
sessions: live.reduce((total, endpoint) => total + (endpoint.sessions?.total ?? 0), 0),
|
|
36
|
+
rss_mb: live.reduce((total, endpoint) => total + (endpoint.rss_mb ?? 0), 0),
|
|
37
|
+
host_rss_mb: live.reduce((total, endpoint) => total + (endpoint.host_rss_mb ?? 0), 0),
|
|
38
|
+
crashes: endpoints.reduce((total, endpoint) => total + (endpoint.crashes ?? 0), 0),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function endpointKind(endpoint) {
|
|
43
|
+
if (endpoint.shard?.kind === "p") return "shard"
|
|
44
|
+
if (endpoint.shard?.kind === "i") return "thread"
|
|
45
|
+
if (typeof endpoint.socket === "string" && basename(endpoint.socket) === "rpc.sock") return "daemon"
|
|
46
|
+
return "endpoint"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function endpointName(endpoint) {
|
|
50
|
+
const kind = endpointKind(endpoint)
|
|
51
|
+
if (kind === "daemon") return "daemon"
|
|
52
|
+
if (kind === "shard") {
|
|
53
|
+
const owner = endpoint.owner?.owner_session_id
|
|
54
|
+
const file = endpoint.owner?.owner_session_file
|
|
55
|
+
const suffix = [owner === undefined ? undefined : `parent ${owner.slice(0, 8)}`, file === undefined ? undefined : basename(file)]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(", ")
|
|
58
|
+
return `shard p-${endpoint.shard.key}${suffix === "" ? "" : ` (${suffix})`}`
|
|
59
|
+
}
|
|
60
|
+
if (kind === "thread") {
|
|
61
|
+
const owner = endpoint.owner?.owner_session_id
|
|
62
|
+
return `thread i-${endpoint.shard.key}${owner === undefined ? "" : ` (thread ${owner.slice(0, 8)})`}`
|
|
63
|
+
}
|
|
64
|
+
return `endpoint ${typeof endpoint.socket === "string" ? basename(endpoint.socket) : basename(endpoint.dir ?? "unknown")}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function metric(value) {
|
|
68
|
+
return value === null || value === undefined ? "?" : String(value)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function runningDetails(endpoint, includeGeneration = true) {
|
|
72
|
+
const parts = [
|
|
73
|
+
`running pid ${metric(endpoint.pid)}`,
|
|
74
|
+
includeGeneration && endpoint.generation !== null && endpoint.generation !== undefined
|
|
75
|
+
? `gen ${endpoint.generation}`
|
|
76
|
+
: undefined,
|
|
77
|
+
endpoint.engineVersion === null || endpoint.engineVersion === undefined ? undefined : `engine ${endpoint.engineVersion}`,
|
|
78
|
+
`${endpoint.sessions?.total ?? 0} session(s)`,
|
|
79
|
+
`rss ${metric(endpoint.rss_mb)} MB (host ${metric(endpoint.host_rss_mb)} MB)`,
|
|
80
|
+
`fds ${metric(endpoint.open_fds)}`,
|
|
81
|
+
`crashes ${endpoint.crashes ?? 0}`,
|
|
82
|
+
].filter(Boolean)
|
|
83
|
+
return parts.join(" · ")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function generationLine(generation, endpoint) {
|
|
87
|
+
const state = generation.current ? "current" : "draining"
|
|
88
|
+
const sessions = generation.sessions ?? endpoint.sessions?.total ?? 0
|
|
89
|
+
return ` gen ${generation.generation} (${state}) pid ${metric(generation.pid)} · engine ${generation.engineVersion ?? "?"} · ${sessions} session(s) · rss ${metric(generation.rss_mb)} MB (host ${metric(generation.host_rss_mb)} MB)`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function formatEndpointLines(endpoints) {
|
|
93
|
+
const lines = []
|
|
94
|
+
for (const endpoint of endpoints) {
|
|
95
|
+
const name = endpointName(endpoint)
|
|
96
|
+
if (!endpoint.reachable) {
|
|
97
|
+
lines.push(`${name}: not running (retained state kept; run omo daemon gc)`)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
lines.push(`${name}: ${runningDetails(endpoint, endpointKind(endpoint) !== "daemon")}`)
|
|
101
|
+
if ((endpoint.generations?.length ?? 0) > 1) {
|
|
102
|
+
for (const generation of endpoint.generations) lines.push(generationLine(generation, endpoint))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const aggregate = aggregateEndpoints(endpoints)
|
|
106
|
+
lines.push(
|
|
107
|
+
`hosts: ${aggregate.live} live (${aggregate.shards} shards, ${aggregate.threads} threads) · ${aggregate.sessions} session(s) · rss ${aggregate.rss_mb} MB (host ${aggregate.host_rss_mb} MB) · crashes ${aggregate.crashes} (recorded, newest 50 per endpoint)`,
|
|
108
|
+
)
|
|
109
|
+
return lines
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function formatDoctorLines(endpoints) {
|
|
113
|
+
return formatEndpointLines(endpoints).map((line) => {
|
|
114
|
+
const prefix = line.startsWith("hosts:")
|
|
115
|
+
? "INFO Hosts:"
|
|
116
|
+
: line.includes(": not running")
|
|
117
|
+
? "WARN "
|
|
118
|
+
: line.startsWith(" gen ")
|
|
119
|
+
? "INFO "
|
|
120
|
+
: "INFO "
|
|
121
|
+
if (line.startsWith("hosts:")) return `${prefix}${line.slice("hosts:".length)}`
|
|
122
|
+
return `${prefix}${line[0]?.toUpperCase() ?? ""}${line.slice(1)}`
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function statusPayload(endpoints) {
|
|
127
|
+
return { endpoints, aggregate: aggregateEndpoints(endpoints) }
|
|
128
|
+
}
|