@kal-elsam/kairo-runtime 0.21.0 → 0.22.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/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/global/architect/architect-store.js +21 -3
- package/src/global/conversation/service.js +10 -1
- package/src/global/intelligence/execution-router.js +5 -9
- package/src/global/intelligence/model-candidate-catalog.js +23 -16
- package/src/global/observability/codex-models.js +1 -1
- package/src/global/observability/codex-usage.js +1 -1
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +9 -1
- package/src/global/runtime/execution-adapters/index.js +12 -1
- package/src/global/runtime/execution-adapters/opencode.js +27 -16
- package/src/global/runtime/run-supervisor.js +29 -6
- package/src/global/runtime/usage-store.js +16 -6
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 0.22.1 — 2026-09-18 (Kairo Runtime)
|
|
9
|
+
|
|
10
|
+
Patch release.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Execution links no longer hardcode `provider: "claude"` on every
|
|
15
|
+
write regardless of which real adapter (Codex, Cursor, OpenCode Go,
|
|
16
|
+
Claude) actually executed the task. `provider` now mirrors the
|
|
17
|
+
link's own real `agentId`, matching what `service.js`'s
|
|
18
|
+
`executionFor` already treated as ground truth at read time. The
|
|
19
|
+
cockpit reads this field directly for its provider display, so every
|
|
20
|
+
task's shown execution provider had always silently said "claude"
|
|
21
|
+
regardless of what actually ran it.
|
|
22
|
+
|
|
23
|
+
## 0.22.0 — 2026-09-18 (Kairo Runtime)
|
|
24
|
+
|
|
25
|
+
Minor release. Promotes OpenCode Go to a real automatic execution
|
|
26
|
+
provider.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- OpenCode Go's accessMode flips from "manual" to "automatic" — its own
|
|
31
|
+
dedicated `/zen/go/*` gateway is a real, separate endpoint from Zen's,
|
|
32
|
+
resolving the earlier billing-attribution concern for Go specifically.
|
|
33
|
+
OpenCode Zen keeps its own real, unresolved Go/Zen billing-attribution
|
|
34
|
+
gap and stays manual.
|
|
35
|
+
- Adds an adapter-declared idle timeout (60s for OpenCode, reset on
|
|
36
|
+
every real output chunk — never an absolute one, so a real
|
|
37
|
+
long-running task is never killed just for taking a while) as the
|
|
38
|
+
safety net for a live-reproduced CLI hang: a genuine hang now becomes
|
|
39
|
+
a bounded, detectable failure instead of an indefinite one.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Three previously-latent bugs, never exercised since Go was never
|
|
44
|
+
launchable before: `resolveExecutionAdapter` threw for
|
|
45
|
+
"opencode-go"/"opencode-zen"; real task execution launched with the
|
|
46
|
+
catalog's bare model id instead of the real "opencode-go/<id>"
|
|
47
|
+
form the CLI needs to route deterministically; the usage/quota
|
|
48
|
+
tracking store rejected "opencode-go"/"opencode-zen" as valid
|
|
49
|
+
providers despite their genuinely separate budgets.
|
|
50
|
+
|
|
8
51
|
## 0.21.0 — 2026-09-18 (Kairo Runtime)
|
|
9
52
|
|
|
10
53
|
Patch-level polish. When a role resolves to a manual-only provider
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kal-elsam/kairo-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Kal-elSam/harness#readme",
|
|
@@ -307,23 +307,41 @@ export async function readExecutionLink(projectRoot, taskId) {
|
|
|
307
307
|
const stat = await statOrNull(paths.executionPath);
|
|
308
308
|
if (!stat) return null;
|
|
309
309
|
const link = JSON.parse(await readFile(paths.executionPath, "utf8"));
|
|
310
|
+
// `provider` is real, structural validation only (a non-empty real
|
|
311
|
+
// string) — never a single hardcoded value. PROJECT TEAM can route a
|
|
312
|
+
// real task to any real adapter (Codex, Claude, Cursor, OpenCode Go),
|
|
313
|
+
// and a legacy artifact from before that existed still carries "claude"
|
|
314
|
+
// for real, so this must accept every real one honestly, never lie
|
|
315
|
+
// by forcing them all to read back as "claude".
|
|
310
316
|
if (link?.schema !== EXECUTION_SCHEMA || link?.taskId !== taskId
|
|
311
|
-
|| !/^run_[a-z0-9_]+$/.test(link?.runId ?? "") || link?.provider !== "
|
|
317
|
+
|| !/^run_[a-z0-9_]+$/.test(link?.runId ?? "") || typeof link?.provider !== "string" || link.provider.length === 0) {
|
|
312
318
|
throw new Error(`Invalid execution artifact: ${taskId}`);
|
|
313
319
|
}
|
|
314
320
|
return link;
|
|
315
321
|
}
|
|
316
322
|
|
|
323
|
+
/**
|
|
324
|
+
* `provider` always mirrors the link's own real `agentId` — the field a
|
|
325
|
+
* real caller (executePlan) actually sets, and the one PROJECT TEAM's own
|
|
326
|
+
* routing decision already named. Never a fabricated or hardcoded value;
|
|
327
|
+
* a legacy caller that never set agentId falls back to "claude" only
|
|
328
|
+
* because that's genuinely what every execution WAS before PROJECT TEAM's
|
|
329
|
+
* multi-provider routing existed — never a guess for real, current data.
|
|
330
|
+
*/
|
|
331
|
+
function resolveExecutionProvider(link) {
|
|
332
|
+
return link?.agentId ?? link?.provider ?? "claude";
|
|
333
|
+
}
|
|
334
|
+
|
|
317
335
|
export async function writeExecutionLink(projectRoot, taskId, link) {
|
|
318
336
|
const paths = await prepareTaskDirectory(projectRoot, taskId);
|
|
319
|
-
const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider:
|
|
337
|
+
const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: resolveExecutionProvider(link) };
|
|
320
338
|
await writeAtomicJson(paths.executionPath, value, { createExclusive: true });
|
|
321
339
|
return value;
|
|
322
340
|
}
|
|
323
341
|
|
|
324
342
|
export async function updateExecutionLink(projectRoot, taskId, link) {
|
|
325
343
|
const paths = await prepareTaskDirectory(projectRoot, taskId);
|
|
326
|
-
const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider:
|
|
344
|
+
const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: resolveExecutionProvider(link) };
|
|
327
345
|
await writeAtomicJson(paths.executionPath, value);
|
|
328
346
|
return value;
|
|
329
347
|
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
buildEfficientTeam, scoreAvailableModels, summarizeCatalogCoverage
|
|
45
45
|
} from "../intelligence/model-intelligence.js";
|
|
46
46
|
import { buildAutomaticExecutionPool, buildCompleteCandidateCatalog, buildRecommendationPool } from "../intelligence/model-candidate-catalog.js";
|
|
47
|
+
import { toRuntimeModelRef } from "../intelligence/transport-registry.js";
|
|
47
48
|
import { createCapabilityRegistry } from "../intelligence/model-capability-registry.js";
|
|
48
49
|
import { ingestArtificialAnalysisEvidence, ingestHuggingFaceLeaderboardEvidence } from "../intelligence/model-capability-registry-sources.js";
|
|
49
50
|
import { ingestOfficialSnapshotEvidence } from "../intelligence/official-benchmark-snapshots.js";
|
|
@@ -1024,7 +1025,15 @@ export function createConversationService(deps = {}) {
|
|
|
1024
1025
|
throw new Error(`Cannot execute "${taskId}": the real project team state changed since this was confirmed (strategy, eligibility, or override) — request a new preview and confirm again.`);
|
|
1025
1026
|
}
|
|
1026
1027
|
const resolvedAgentId = resolvedCandidate.adapterId;
|
|
1027
|
-
|
|
1028
|
+
// OpenCode Go/Zen's real catalog stores bare model ids (see
|
|
1029
|
+
// opencode-models.js's normalizeModel) — the CLI needs the real,
|
|
1030
|
+
// fully-qualified "opencode-go/<id>" (or "opencode/<id>" for Zen)
|
|
1031
|
+
// ref to deterministically route to the intended product; a bare id
|
|
1032
|
+
// is exactly the ambiguity Kairo must never risk. Every other
|
|
1033
|
+
// adapter's modelId is already launch-ready as-is.
|
|
1034
|
+
const resolvedModel = resolvedAgentId === "opencode-go" || resolvedAgentId === "opencode-zen"
|
|
1035
|
+
? toRuntimeModelRef(resolvedAgentId === "opencode-go" ? "go" : "zen", resolvedCandidate.modelId)
|
|
1036
|
+
: resolvedCandidate.modelId;
|
|
1028
1037
|
|
|
1029
1038
|
const runId = newRunId();
|
|
1030
1039
|
const createdAt = new Date().toISOString();
|
|
@@ -174,15 +174,11 @@ export function checkCandidate(adapterId, { adapters, codexUsage, claudeUsage, o
|
|
|
174
174
|
const adapter = findAdapter(adapterId, adapters);
|
|
175
175
|
if (!adapter) return { ok: false, reason: `${adapterId}: no adapter found` };
|
|
176
176
|
if (!adapter.available) return { ok: false, reason: adapter.reason ?? `${adapterId}: not available` };
|
|
177
|
-
// "launchable" means safe for Kairo to invoke programmatically —
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
// this exemption — its real launchability is judged the same way for
|
|
183
|
-
// both recommendation and execution now.
|
|
184
|
-
const launchableRequired = requireLaunchable || adapterId !== "opencode-go";
|
|
185
|
-
if (launchableRequired && !adapter.launchable) return { ok: false, reason: adapter.reason ?? `${adapterId}: not launchable yet` };
|
|
177
|
+
// "launchable" means safe for Kairo to invoke programmatically — every
|
|
178
|
+
// real adapter is now judged the same way for both recommendation and
|
|
179
|
+
// execution; no adapter keeps a special exemption anymore (Cursor and
|
|
180
|
+
// OpenCode Go both dropped theirs once their real adapters proved out).
|
|
181
|
+
if (!adapter.launchable) return { ok: false, reason: adapter.reason ?? `${adapterId}: not launchable yet` };
|
|
186
182
|
|
|
187
183
|
if (adapterId === "codex") {
|
|
188
184
|
const left = remainingPercent(codexUsage);
|
|
@@ -39,7 +39,7 @@ import { matchArtificialAnalysisScore } from "./model-intelligence.js";
|
|
|
39
39
|
* @property {string} modelName - human-readable clean name, with real effort/context/privacy variant tokens stripped (see stripDisplayVariant). Never invented — always derived from the provider's own real displayName.
|
|
40
40
|
* @property {string} rawDisplayName - the provider's own displayName, completely unmodified — the real evidence modelName was derived from. Whatever stripDisplayVariant peeled off (effort/context/privacy tokens) to produce modelName is still visible here, never a separate field: /models --evidence's own "technical detail" is just this string.
|
|
41
41
|
* @property {string} adapterId - "codex" | "claude" | "cursor" | "opencode-go".
|
|
42
|
-
* @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor's own "auto" router model always; OpenCode
|
|
42
|
+
* @property {"automatic"|"manual"} accessMode - whether Kairo can actually launch this candidate itself right now, or whether it's a real, recommendable option the human runs manually (Cursor's own "auto" router model always; OpenCode Zen always, pending its own Go/Zen billing-attribution proof — see this module's own doc). Named Cursor and OpenCode Go models are automatic.
|
|
43
43
|
* @property {"scored"|"partial"|"unscored"} evidenceStatus - "scored": AA matched this exact model AND reports at least one of intelligenceIndex/codingIndex. "partial": AA matched it but both composite indices are null (real match, thin evidence). "unscored": no confident AA match at all. Never role-specific — see this module's own doc for why.
|
|
44
44
|
* @property {string|null} lineageKey - real, recognized model family/lineage (see LINEAGE_PARSERS) — null when the modelId doesn't match any recognized, conservative pattern. Never guessed.
|
|
45
45
|
* @property {number|null} generation - a real, comparable version number within that lineage — null whenever lineageKey is null.
|
|
@@ -281,20 +281,27 @@ function applyLifecycle(catalog) {
|
|
|
281
281
|
// Whether Kairo can actually launch a candidate itself right now, per
|
|
282
282
|
// adapter — real, current state (execution-adapters/index.js's own
|
|
283
283
|
// `launchable` flags, intelligence/execution-router.js's checkCandidate),
|
|
284
|
-
// not a guess.
|
|
285
|
-
//
|
|
286
|
-
// (`
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
284
|
+
// not a guess. Cursor is "automatic": its own execution adapter
|
|
285
|
+
// (execution-adapters/cursor.js) already builds a real, auditable
|
|
286
|
+
// non-interactive launch (`cursor-agent -p --output-format stream-json`)
|
|
287
|
+
// and parses its structured event stream, the exact same shape as
|
|
288
|
+
// Codex/Claude — Cursor's own docs explicitly support this (headless/CI
|
|
289
|
+
// use is an intended, documented capability, not a hack).
|
|
290
|
+
//
|
|
291
|
+
// OpenCode Go is "automatic" too now, but with a real, confirmed caveat:
|
|
292
|
+
// a live reproduction (2026-09-18, against a real account) found the
|
|
293
|
+
// `opencode` CLI genuinely hangs with zero output for some real Go
|
|
294
|
+
// models — not a stale finding. What makes this safe to flip anyway is
|
|
295
|
+
// the real idle timeout the execution adapter itself now declares
|
|
296
|
+
// (execution-adapters/opencode.js's OPENCODE_IDLE_TIMEOUT_MS,
|
|
297
|
+
// run-supervisor.js resets it on every real chunk) — a genuine hang gets
|
|
298
|
+
// converted into a real FAILED run within a bounded, known time instead
|
|
299
|
+
// of hanging forever, which is exactly what the human's own explicit
|
|
300
|
+
// decision requires: try Go, and if it's not OK, automatically move to
|
|
301
|
+
// the next real available model. OpenCode Zen stays "manual" — the
|
|
302
|
+
// real, confirmed Go/Zen billing-attribution gap (see this module's own
|
|
303
|
+
// doc above) is unrelated to this and still unresolved.
|
|
304
|
+
const ACCESS_MODE_BY_ADAPTER = { codex: "automatic", claude: "automatic", cursor: "automatic", "opencode-go": "automatic" };
|
|
298
305
|
|
|
299
306
|
function resolveAccessMode(adapterId, modelId) {
|
|
300
307
|
// Cursor's own "auto" router picks whichever underlying model it wants
|
|
@@ -447,7 +454,7 @@ export function buildRecommendationPool(scoredAll, completeCatalog) {
|
|
|
447
454
|
* The Automatic Execution Pool: the subset of the Recommendation Pool
|
|
448
455
|
* Kairo can actually launch itself, right now — real routing's own
|
|
449
456
|
* candidate source, never QUALITY/EFFICIENT TEAM's. Requires BOTH a real
|
|
450
|
-
* accessMode of "automatic" (OpenCode
|
|
457
|
+
* accessMode of "automatic" (OpenCode Zen is currently manual, and
|
|
451
458
|
* Cursor's own "auto" router model stays manual — see
|
|
452
459
|
* ModelCandidateIdentity's own doc) AND real,
|
|
453
460
|
* current eligibility (adapter availability, quota, launchability — the
|
|
@@ -89,7 +89,7 @@ export async function readCodexModels({
|
|
|
89
89
|
child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before model list")); });
|
|
90
90
|
|
|
91
91
|
writeRequest(child, 1, "initialize", {
|
|
92
|
-
clientInfo: { name: "kairo", title: "Kairo", version: "0.
|
|
92
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.22.1" },
|
|
93
93
|
capabilities: {}
|
|
94
94
|
});
|
|
95
95
|
});
|
|
@@ -151,7 +151,7 @@ export async function readCodexUsage({
|
|
|
151
151
|
child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before rate limits")); });
|
|
152
152
|
|
|
153
153
|
writeRequest(child, 1, "initialize", {
|
|
154
|
-
clientInfo: { name: "kairo", title: "Kairo", version: "0.
|
|
154
|
+
clientInfo: { name: "kairo", title: "Kairo", version: "0.22.1" },
|
|
155
155
|
capabilities: {}
|
|
156
156
|
});
|
|
157
157
|
});
|
|
@@ -12,12 +12,20 @@ export function createExecutionAdapter({
|
|
|
12
12
|
parseEventLine = null,
|
|
13
13
|
checkAvailability = null,
|
|
14
14
|
launchable = null,
|
|
15
|
-
preflight = null
|
|
15
|
+
preflight = null,
|
|
16
|
+
idleTimeoutMs = null
|
|
16
17
|
}) {
|
|
17
18
|
return {
|
|
18
19
|
id,
|
|
19
20
|
label,
|
|
20
21
|
executable,
|
|
22
|
+
// The real, adapter-declared "no output for this long means genuinely
|
|
23
|
+
// hung, not just working" threshold (run-supervisor.js resets this on
|
|
24
|
+
// every stdout/stderr chunk, so a real long-running task is never
|
|
25
|
+
// killed just for taking a while — only real silence trips it). null
|
|
26
|
+
// (the default) means this adapter is trusted not to hang; only an
|
|
27
|
+
// adapter with a real, observed hanging failure mode declares one.
|
|
28
|
+
idleTimeoutMs,
|
|
21
29
|
capabilities: {
|
|
22
30
|
structuredEvents: false,
|
|
23
31
|
tokens: false,
|
|
@@ -12,8 +12,19 @@ export function listExecutionAdapters() {
|
|
|
12
12
|
return [...EXECUTION_ADAPTERS];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the real adapter object for an id — "opencode-go"/"opencode-zen"
|
|
17
|
+
* both share the single real "opencode" adapter object (one executable,
|
|
18
|
+
* one launch/parse contract; the Go/Zen split is a routing/eligibility
|
|
19
|
+
* distinction, decided by execution-router.js's checkCandidate, never a
|
|
20
|
+
* separate adapter object), the same prefix rule execution-router.js's own
|
|
21
|
+
* findAdapter already uses. Never silently falls through for an unrelated
|
|
22
|
+
* unknown id — only an id that is exactly "opencode" or starts with
|
|
23
|
+
* "opencode-" maps this way.
|
|
24
|
+
*/
|
|
15
25
|
export function resolveExecutionAdapter(id) {
|
|
16
|
-
const
|
|
26
|
+
const baseId = id === "opencode" || id.startsWith("opencode-") ? "opencode" : id;
|
|
27
|
+
const adapter = EXECUTION_ADAPTERS.find((candidate) => candidate.id === baseId);
|
|
17
28
|
if (!adapter) {
|
|
18
29
|
throw new Error(`Unknown execution adapter "${id}". Use ${EXECUTION_ADAPTER_IDS.join(", ")}.`);
|
|
19
30
|
}
|
|
@@ -3,6 +3,17 @@ import { isExecutableAvailable } from "../../cli-probe.js";
|
|
|
3
3
|
|
|
4
4
|
const EXECUTABLE = "opencode";
|
|
5
5
|
|
|
6
|
+
// Real, reproduced failure mode (2026-09-18): `opencode run -m
|
|
7
|
+
// opencode-go/<model>` genuinely hangs with ZERO stdout/stderr output for
|
|
8
|
+
// several real models, confirmed live against a real account — not a
|
|
9
|
+
// stale finding. Some other real models fail fast and cleanly instead
|
|
10
|
+
// (e.g. a real 403 region-lock error came back instantly). Since a real,
|
|
11
|
+
// legitimate task can genuinely run for minutes while still producing
|
|
12
|
+
// real output, this is an IDLE timeout (reset on every real chunk, see
|
|
13
|
+
// run-supervisor.js), never an absolute one — only genuine silence this
|
|
14
|
+
// long trips it.
|
|
15
|
+
export const OPENCODE_IDLE_TIMEOUT_MS = 60_000;
|
|
16
|
+
|
|
6
17
|
// Verified live (`opencode run --format json`) against a real account: the
|
|
7
18
|
// CLI genuinely emits parseable NDJSON events — step_start/step_finish,
|
|
8
19
|
// tool_use (with real tool name + status), text, and error — including
|
|
@@ -10,19 +21,18 @@ const EXECUTABLE = "opencode";
|
|
|
10
21
|
// step_finish. That earlier "does not emit auditable structured events"
|
|
11
22
|
// claim was wrong; structuredEvents below is now an accurate capability.
|
|
12
23
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// — no more paid trial invocations to "just check" this again.
|
|
24
|
+
// `launchable: true` now — for Go specifically. This adapter is shared by
|
|
25
|
+
// both OpenCode Go (subscription, $10/mo) and OpenCode Zen (PAYG); the
|
|
26
|
+
// real, unresolved billing-attribution gap between them (no per-event way
|
|
27
|
+
// to prove which tier served a request) is still real, but
|
|
28
|
+
// execution-router.js's checkCandidate refuses "opencode-zen" outright
|
|
29
|
+
// regardless of this flag, so this flag only ever matters for
|
|
30
|
+
// "opencode-go" in practice — and for Go, that gap doesn't apply (its
|
|
31
|
+
// own dedicated `/zen/go/*` endpoint is a real, separate gateway, see
|
|
32
|
+
// model-candidate-catalog.js's own doc). The real remaining risk was a
|
|
33
|
+
// live-confirmed hang for some Go models, not billing — mitigated by this
|
|
34
|
+
// adapter's own idleTimeoutMs (see above) converting a genuine hang into
|
|
35
|
+
// a bounded real failure instead of an indefinite one.
|
|
26
36
|
function checkOpencodeAvailability(context = {}) {
|
|
27
37
|
const available = isExecutableAvailable(EXECUTABLE, { env: context.env ?? process.env });
|
|
28
38
|
if (!available) {
|
|
@@ -32,8 +42,8 @@ function checkOpencodeAvailability(context = {}) {
|
|
|
32
42
|
};
|
|
33
43
|
}
|
|
34
44
|
return {
|
|
35
|
-
available: true, compatible: true, launchable:
|
|
36
|
-
reason:
|
|
45
|
+
available: true, compatible: true, launchable: true,
|
|
46
|
+
reason: null
|
|
37
47
|
};
|
|
38
48
|
}
|
|
39
49
|
|
|
@@ -100,5 +110,6 @@ export default createExecutionAdapter({
|
|
|
100
110
|
},
|
|
101
111
|
checkAvailability: checkOpencodeAvailability,
|
|
102
112
|
buildLaunch: buildOpencodeLaunch,
|
|
103
|
-
parseEventLine: parseOpencodeEventLine
|
|
113
|
+
parseEventLine: parseOpencodeEventLine,
|
|
114
|
+
idleTimeoutMs: OPENCODE_IDLE_TIMEOUT_MS
|
|
104
115
|
});
|
|
@@ -78,6 +78,13 @@ export async function supervisePreparedRun({
|
|
|
78
78
|
}) {
|
|
79
79
|
const handoff = await consumeRunHandoff(homeDir, runId);
|
|
80
80
|
const adapter = resolveAdapterImpl(handoff.agentId);
|
|
81
|
+
// A caller-supplied timeoutMs always wins; otherwise the adapter's own
|
|
82
|
+
// declared idleTimeoutMs applies automatically — this is the ONLY path
|
|
83
|
+
// real detached runs (the production `wait: false` case) ever take,
|
|
84
|
+
// since the handoff itself never carries a caller timeoutMs (see
|
|
85
|
+
// run-manager.js's writeRunHandoff), so an adapter-level default is the
|
|
86
|
+
// only way a real detached opencode-go run ever gets one at all.
|
|
87
|
+
const effectiveTimeoutMs = timeoutMs ?? adapter.idleTimeoutMs ?? null;
|
|
81
88
|
let metadata = await readRunState(homeDir, runId);
|
|
82
89
|
|
|
83
90
|
if (!metadata) {
|
|
@@ -310,9 +317,12 @@ export async function supervisePreparedRun({
|
|
|
310
317
|
|
|
311
318
|
const failed = exitCode !== 0;
|
|
312
319
|
const nextState = failed ? RUN_STATES.FAILED : RUN_STATES.COMPLETED;
|
|
320
|
+
const failureReason = timedOutByIdle
|
|
321
|
+
? `No real output for ${effectiveTimeoutMs}ms (idle timeout) — likely hung, not a normal completion`
|
|
322
|
+
: `Process exited with code ${exitCode}`;
|
|
313
323
|
metadata = transitionRunState(metadata, nextState, {
|
|
314
324
|
exitCode,
|
|
315
|
-
error: failed ?
|
|
325
|
+
error: failed ? failureReason : null
|
|
316
326
|
});
|
|
317
327
|
await writeRunState(homeDir, metadata);
|
|
318
328
|
await appendRunEvent(homeDir, createRunEvent({
|
|
@@ -341,7 +351,23 @@ export async function supervisePreparedRun({
|
|
|
341
351
|
});
|
|
342
352
|
});
|
|
343
353
|
|
|
354
|
+
// An IDLE timeout, never an absolute one: a real, legitimate task can run
|
|
355
|
+
// for many minutes while still producing real output, so the timer is
|
|
356
|
+
// rearmed on every real stdout/stderr chunk (see opencode.js's own
|
|
357
|
+
// idleTimeoutMs doc — this only ever exists to catch genuine silence,
|
|
358
|
+
// like the real hang confirmed there, not to cap a working run's length).
|
|
359
|
+
let timedOutByIdle = false;
|
|
360
|
+
const armIdleTimeout = () => {
|
|
361
|
+
if (effectiveTimeoutMs == null || effectiveTimeoutMs <= 0) return;
|
|
362
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
363
|
+
timeoutHandle = setTimeout(() => {
|
|
364
|
+
timedOutByIdle = true;
|
|
365
|
+
child.kill("SIGTERM");
|
|
366
|
+
}, effectiveTimeoutMs);
|
|
367
|
+
};
|
|
368
|
+
|
|
344
369
|
child.stdout.on("data", (chunk) => {
|
|
370
|
+
armIdleTimeout();
|
|
345
371
|
enqueue(async () => {
|
|
346
372
|
stdoutBuffer += chunk.toString();
|
|
347
373
|
stdoutBuffer = await flushBuffer(stdoutBuffer, "stdout");
|
|
@@ -349,17 +375,14 @@ export async function supervisePreparedRun({
|
|
|
349
375
|
});
|
|
350
376
|
|
|
351
377
|
child.stderr.on("data", (chunk) => {
|
|
378
|
+
armIdleTimeout();
|
|
352
379
|
enqueue(async () => {
|
|
353
380
|
stderrBuffer += chunk.toString();
|
|
354
381
|
stderrBuffer = await flushBuffer(stderrBuffer, "stderr");
|
|
355
382
|
});
|
|
356
383
|
});
|
|
357
384
|
|
|
358
|
-
|
|
359
|
-
timeoutHandle = setTimeout(() => {
|
|
360
|
-
child.kill("SIGTERM");
|
|
361
|
-
}, timeoutMs);
|
|
362
|
-
}
|
|
385
|
+
armIdleTimeout();
|
|
363
386
|
|
|
364
387
|
void serializeStateWrite(async () => {
|
|
365
388
|
metadata = {
|
|
@@ -7,6 +7,17 @@ import { writeAtomicJson } from "./write-atomic-json.js";
|
|
|
7
7
|
|
|
8
8
|
const writeLocks = new Map();
|
|
9
9
|
|
|
10
|
+
// OpenCode Go and Zen share a single real execution adapter object (one
|
|
11
|
+
// executable, one launch/parse contract — see
|
|
12
|
+
// execution-adapters/index.js's own resolveExecutionAdapter doc), but
|
|
13
|
+
// they are NOT the same thing for usage/budget tracking: Go is a flat
|
|
14
|
+
// $10/mo subscription with its own weekly/monthly quota, Zen is pay-per-
|
|
15
|
+
// token with its own real balance — genuinely separate budgets that must
|
|
16
|
+
// never share one usage record. So this whitelist is deliberately wider
|
|
17
|
+
// than EXECUTION_ADAPTER_IDS, not a mirror of it.
|
|
18
|
+
const EXTRA_USAGE_PROVIDER_IDS = ["opencode-go", "opencode-zen"];
|
|
19
|
+
const VALID_USAGE_PROVIDER_IDS = [...EXECUTION_ADAPTER_IDS, ...EXTRA_USAGE_PROVIDER_IDS];
|
|
20
|
+
|
|
10
21
|
export function getUsageDir(homeDir) {
|
|
11
22
|
return harnessHomePaths(homeDir).usageDir;
|
|
12
23
|
}
|
|
@@ -15,13 +26,12 @@ export function getUsageDir(homeDir) {
|
|
|
15
26
|
* The one real boundary a provider id gets validated at before it's ever
|
|
16
27
|
* used to build a path — the same defense-in-depth role
|
|
17
28
|
* assertWorktreeId/assertTaskId already play elsewhere. Provider ids are a
|
|
18
|
-
* real, closed list
|
|
19
|
-
*
|
|
20
|
-
* to surface loudly rather than a path to silently sanitize.
|
|
29
|
+
* real, closed list, never a free-form string, so an unknown one is
|
|
30
|
+
* always a bug to surface loudly rather than a path to silently sanitize.
|
|
21
31
|
*/
|
|
22
32
|
function usagePath(homeDir, provider) {
|
|
23
|
-
if (!
|
|
24
|
-
throw new Error(`Unknown provider "${provider}" for usage tracking. Use ${
|
|
33
|
+
if (!VALID_USAGE_PROVIDER_IDS.includes(provider)) {
|
|
34
|
+
throw new Error(`Unknown provider "${provider}" for usage tracking. Use ${VALID_USAGE_PROVIDER_IDS.join(", ")}.`);
|
|
25
35
|
}
|
|
26
36
|
return join(getUsageDir(homeDir), `${provider}.json`);
|
|
27
37
|
}
|
|
@@ -60,7 +70,7 @@ export async function listProviderUsage(homeDir) {
|
|
|
60
70
|
for (const entry of entries) {
|
|
61
71
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
62
72
|
const provider = entry.name.slice(0, -".json".length);
|
|
63
|
-
if (!
|
|
73
|
+
if (!VALID_USAGE_PROVIDER_IDS.includes(provider)) continue;
|
|
64
74
|
const record = await readProviderUsage(homeDir, provider);
|
|
65
75
|
if (record) records.push(record);
|
|
66
76
|
}
|