@evident-ai/cli 3.0.1-dev.116734c → 3.0.1-dev.23d3337
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/dist/index.js +706 -260
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
+
import { createRequire } from "module";
|
|
4
5
|
import { Command } from "commander";
|
|
5
6
|
|
|
6
7
|
// src/commands/login.ts
|
|
@@ -470,6 +471,12 @@ import chalk6 from "chalk";
|
|
|
470
471
|
import ora3 from "ora";
|
|
471
472
|
import { select as select3 } from "@inquirer/prompts";
|
|
472
473
|
|
|
474
|
+
// ../../packages/types/src/opencode/index.ts
|
|
475
|
+
function opencodeMessageIdFor(queuedMessageId) {
|
|
476
|
+
const sanitized = queuedMessageId.replace(/[^a-zA-Z0-9]/g, "_");
|
|
477
|
+
return `msg_${sanitized}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
473
480
|
// ../../packages/types/src/telemetry/index.ts
|
|
474
481
|
var TelemetryEventTypes = {
|
|
475
482
|
// Agent activity events (shown in web UI activity log)
|
|
@@ -484,6 +491,29 @@ var TelemetryEventTypes = {
|
|
|
484
491
|
var MAX_FRAME_BYTES = 256 * 1024;
|
|
485
492
|
var TUNNEL_DRAIN_PING_PATH = "/__evident/drain";
|
|
486
493
|
|
|
494
|
+
// ../../packages/types/src/logging/index.ts
|
|
495
|
+
var CORRELATION_ID_HEADER = "x-evident-correlation-id";
|
|
496
|
+
function log(level, event, fields) {
|
|
497
|
+
const method = level === "debug" ? "log" : level;
|
|
498
|
+
try {
|
|
499
|
+
console[method]("[evident]", JSON.stringify({ level, event, ...fields }));
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error(
|
|
502
|
+
"[evident] log_serialize_failed",
|
|
503
|
+
event,
|
|
504
|
+
err instanceof Error ? err.message : String(err)
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function stripQuery(url) {
|
|
509
|
+
try {
|
|
510
|
+
return new URL(url).pathname;
|
|
511
|
+
} catch {
|
|
512
|
+
const q = url.indexOf("?");
|
|
513
|
+
return q === -1 ? url : url.slice(0, q);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
487
517
|
// src/lib/telemetry.ts
|
|
488
518
|
var CLI_VERSION = process.env.npm_package_version || "unknown";
|
|
489
519
|
var eventBuffer = [];
|
|
@@ -684,6 +714,19 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
684
714
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
685
715
|
}
|
|
686
716
|
|
|
717
|
+
// src/lib/opencode/opencode-version-gate.ts
|
|
718
|
+
var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
|
|
719
|
+
function isQueueValidatedVersion(version2) {
|
|
720
|
+
if (!version2) return false;
|
|
721
|
+
return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version2);
|
|
722
|
+
}
|
|
723
|
+
function buildOpenCodeVersionWarning(version2) {
|
|
724
|
+
if (isQueueValidatedVersion(version2)) return null;
|
|
725
|
+
const detected = version2 ? `v${version2}` : "unknown";
|
|
726
|
+
const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
|
|
727
|
+
return `Warning: opencode ${detected} is not a queue-validated version (validated: ${validated}). Native message queuing \u2014 which channel (Slack/WhatsApp) message handling relies on \u2014 is unverified on this version; queued/follow-up messages may behave unexpectedly. Continuing anyway. Bumping the validated set requires re-running the queue validation.`;
|
|
728
|
+
}
|
|
729
|
+
|
|
687
730
|
// src/lib/opencode/process.ts
|
|
688
731
|
import { execSync, spawn } from "child_process";
|
|
689
732
|
var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
|
|
@@ -998,21 +1041,27 @@ function completedOf(m) {
|
|
|
998
1041
|
if (!m || typeof m !== "object") return void 0;
|
|
999
1042
|
return m.info?.time?.completed;
|
|
1000
1043
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
return Array.isArray(body) ? body : null;
|
|
1007
|
-
} catch {
|
|
1008
|
-
return null;
|
|
1009
|
-
}
|
|
1044
|
+
function idOf(m) {
|
|
1045
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1046
|
+
if (typeof m.id === "string") return m.id;
|
|
1047
|
+
const infoId = m.info?.id;
|
|
1048
|
+
return typeof infoId === "string" ? infoId : void 0;
|
|
1010
1049
|
}
|
|
1011
|
-
function
|
|
1012
|
-
if (!
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
return
|
|
1050
|
+
function parentIdOf(m) {
|
|
1051
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1052
|
+
if (typeof m.parentID === "string") return m.parentID;
|
|
1053
|
+
const infoParent = m.info?.parentID;
|
|
1054
|
+
return typeof infoParent === "string" ? infoParent : void 0;
|
|
1055
|
+
}
|
|
1056
|
+
function finishOf(m) {
|
|
1057
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1058
|
+
if (typeof m.finish === "string") return m.finish;
|
|
1059
|
+
const infoFinish = m.info?.finish;
|
|
1060
|
+
return typeof infoFinish === "string" ? infoFinish : void 0;
|
|
1061
|
+
}
|
|
1062
|
+
function isAssistantInFlight(m) {
|
|
1063
|
+
if (completedOf(m) == null) return true;
|
|
1064
|
+
return finishOf(m) === "tool-calls";
|
|
1016
1065
|
}
|
|
1017
1066
|
async function createOpenCodeSession(port, directory) {
|
|
1018
1067
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
@@ -1031,8 +1080,9 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1031
1080
|
const data = await response.json();
|
|
1032
1081
|
return data.id;
|
|
1033
1082
|
}
|
|
1034
|
-
async function
|
|
1083
|
+
async function sendPromptAsync(port, sessionId, content, options, messageId) {
|
|
1035
1084
|
const body = {
|
|
1085
|
+
messageID: messageId,
|
|
1036
1086
|
parts: [{ type: "text", text: content }]
|
|
1037
1087
|
};
|
|
1038
1088
|
if (options?.agent) {
|
|
@@ -1047,79 +1097,63 @@ async function sendMessageToOpenCode(port, sessionId, content, options, hooks, m
|
|
|
1047
1097
|
};
|
|
1048
1098
|
}
|
|
1049
1099
|
}
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const reportedInteraction = reportedQuestions.size > 0 || reportedPermissions.size > 0;
|
|
1108
|
-
const turnComplete = isTurnComplete(await getSessionMessages(port, sessionId));
|
|
1109
|
-
const awaitingInteraction = reportedInteraction && !turnComplete;
|
|
1110
|
-
return { title: session?.title, awaitingInteraction };
|
|
1111
|
-
} catch (err) {
|
|
1112
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
1113
|
-
throw new Error("Message processing timed out");
|
|
1114
|
-
}
|
|
1115
|
-
throw err;
|
|
1116
|
-
} finally {
|
|
1117
|
-
clearTimeout(timer);
|
|
1118
|
-
pollDone = true;
|
|
1119
|
-
}
|
|
1120
|
-
};
|
|
1121
|
-
const [result] = await Promise.all([sendMessage(), pollInteractive()]);
|
|
1122
|
-
return result;
|
|
1100
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/prompt_async`, {
|
|
1101
|
+
method: "POST",
|
|
1102
|
+
headers: { "Content-Type": "application/json" },
|
|
1103
|
+
body: JSON.stringify(body)
|
|
1104
|
+
});
|
|
1105
|
+
if (res.status < 200 || res.status >= 300) {
|
|
1106
|
+
const text = await res.text().catch(() => "");
|
|
1107
|
+
throw new Error(`OpenCode prompt_async failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
function findAssistantReplyAfter(messages, userMessageId) {
|
|
1111
|
+
if (!messages || messages.length === 0) return null;
|
|
1112
|
+
const byParent = messages.find(
|
|
1113
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) === userMessageId
|
|
1114
|
+
);
|
|
1115
|
+
if (byParent) return byParent;
|
|
1116
|
+
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1117
|
+
if (userIndex === -1) return null;
|
|
1118
|
+
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1119
|
+
if (roleOf(messages[i]) === "assistant") return messages[i];
|
|
1120
|
+
}
|
|
1121
|
+
return null;
|
|
1122
|
+
}
|
|
1123
|
+
function findLastAssistantReplyFor(messages, userMessageId) {
|
|
1124
|
+
if (!messages || messages.length === 0) return null;
|
|
1125
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1126
|
+
const m = messages[i];
|
|
1127
|
+
if (roleOf(m) === "assistant" && parentIdOf(m) === userMessageId) return m;
|
|
1128
|
+
}
|
|
1129
|
+
const userIndex = messages.findIndex((m) => idOf(m) === userMessageId);
|
|
1130
|
+
if (userIndex === -1) return null;
|
|
1131
|
+
let last = null;
|
|
1132
|
+
for (let i = userIndex + 1; i < messages.length; i++) {
|
|
1133
|
+
const role = roleOf(messages[i]);
|
|
1134
|
+
if (role === "user") break;
|
|
1135
|
+
if (role === "assistant") last = messages[i];
|
|
1136
|
+
}
|
|
1137
|
+
return last;
|
|
1138
|
+
}
|
|
1139
|
+
function messageRunState(messages, userMessageId) {
|
|
1140
|
+
if (!messages || messages.length === 0) return "unknown";
|
|
1141
|
+
const hasUser = messages.some((m) => idOf(m) === userMessageId);
|
|
1142
|
+
const reply = findLastAssistantReplyFor(messages, userMessageId);
|
|
1143
|
+
if (!hasUser) {
|
|
1144
|
+
if (!reply) return "unknown";
|
|
1145
|
+
}
|
|
1146
|
+
if (!reply) return "queued";
|
|
1147
|
+
return isAssistantInFlight(reply) ? "running" : "done";
|
|
1148
|
+
}
|
|
1149
|
+
function hasRunningAssistantExcept(messages, exceptUserMessageId) {
|
|
1150
|
+
if (!messages || messages.length === 0) return false;
|
|
1151
|
+
return messages.some(
|
|
1152
|
+
(m) => roleOf(m) === "assistant" && parentIdOf(m) !== exceptUserMessageId && isAssistantInFlight(m)
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
function opencodeMessageIdFor2(queuedMessageId) {
|
|
1156
|
+
return opencodeMessageIdFor(queuedMessageId);
|
|
1123
1157
|
}
|
|
1124
1158
|
|
|
1125
1159
|
// src/lib/tunnel/connection.ts
|
|
@@ -1190,12 +1224,20 @@ var StreamForwarder = class {
|
|
|
1190
1224
|
}
|
|
1191
1225
|
async handleOpen(frame) {
|
|
1192
1226
|
const { sid, method, path, headers, has_body } = frame;
|
|
1227
|
+
const correlationId = headers?.[CORRELATION_ID_HEADER];
|
|
1228
|
+
const startedAt = Date.now();
|
|
1193
1229
|
if (path === TUNNEL_DRAIN_PING_PATH) {
|
|
1194
1230
|
this.callbacks.onDrainPing?.();
|
|
1195
1231
|
this.send({ type: "head", sid, status: 204, headers: {} });
|
|
1196
1232
|
this.send({ type: "res_end", sid });
|
|
1197
1233
|
return;
|
|
1198
1234
|
}
|
|
1235
|
+
log("info", "agent_request", {
|
|
1236
|
+
correlation_id: correlationId,
|
|
1237
|
+
sid,
|
|
1238
|
+
method,
|
|
1239
|
+
path: stripQuery(path)
|
|
1240
|
+
});
|
|
1199
1241
|
const ac = new AbortController();
|
|
1200
1242
|
let bodyPromise;
|
|
1201
1243
|
let pushBody;
|
|
@@ -1242,6 +1284,12 @@ var StreamForwarder = class {
|
|
|
1242
1284
|
if (!STRIP_RES.has(key.toLowerCase())) resHeaders[key] = value;
|
|
1243
1285
|
});
|
|
1244
1286
|
this.send({ type: "head", sid, status: upstream.status, headers: resHeaders });
|
|
1287
|
+
log("info", "agent_response", {
|
|
1288
|
+
correlation_id: correlationId,
|
|
1289
|
+
sid,
|
|
1290
|
+
status: upstream.status,
|
|
1291
|
+
duration_ms: Date.now() - startedAt
|
|
1292
|
+
});
|
|
1245
1293
|
this.callbacks.onHead?.(sid, upstream.status);
|
|
1246
1294
|
try {
|
|
1247
1295
|
if (upstream.body) {
|
|
@@ -1498,6 +1546,12 @@ var RunnerConnection = class {
|
|
|
1498
1546
|
};
|
|
1499
1547
|
|
|
1500
1548
|
// src/lib/channels/driver.ts
|
|
1549
|
+
function messageIdOf(m) {
|
|
1550
|
+
if (!m || typeof m !== "object") return void 0;
|
|
1551
|
+
if (typeof m.id === "string") return m.id;
|
|
1552
|
+
const infoId = m.info?.id;
|
|
1553
|
+
return typeof infoId === "string" ? infoId : void 0;
|
|
1554
|
+
}
|
|
1501
1555
|
var DEFAULT_RETRY_POLICY = {
|
|
1502
1556
|
maxAttempts: 6,
|
|
1503
1557
|
baseDelayMs: 500,
|
|
@@ -1505,12 +1559,22 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1505
1559
|
};
|
|
1506
1560
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1507
1561
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1562
|
+
var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
|
|
1563
|
+
var DEFAULT_STUCK_QUEUED_MS = 6e4;
|
|
1508
1564
|
var ChannelAuthError = class extends Error {
|
|
1509
1565
|
constructor(message) {
|
|
1510
1566
|
super(message);
|
|
1511
1567
|
this.name = "ChannelAuthError";
|
|
1512
1568
|
}
|
|
1513
1569
|
};
|
|
1570
|
+
var ChannelTerminalError = class extends Error {
|
|
1571
|
+
status;
|
|
1572
|
+
constructor(message, status) {
|
|
1573
|
+
super(message);
|
|
1574
|
+
this.name = "ChannelTerminalError";
|
|
1575
|
+
this.status = status;
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1514
1578
|
function backoffDelay(attempt, policy) {
|
|
1515
1579
|
const exp = policy.baseDelayMs * Math.pow(2, attempt);
|
|
1516
1580
|
const capped = Math.min(policy.maxDelayMs, exp);
|
|
@@ -1531,15 +1595,27 @@ var ChannelDriver = class {
|
|
|
1531
1595
|
sleep;
|
|
1532
1596
|
pausedPollIntervalMs;
|
|
1533
1597
|
pausedMaxWaitMs;
|
|
1598
|
+
dispatchConfirmMs;
|
|
1599
|
+
stuckQueuedMs;
|
|
1600
|
+
now;
|
|
1534
1601
|
/** Cache of conversationId → opencode sessionId. */
|
|
1535
1602
|
sessions = /* @__PURE__ */ new Map();
|
|
1536
1603
|
/**
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
*
|
|
1540
|
-
*
|
|
1604
|
+
* Per-SESSION watchers (WI-3), keyed by opencode sessionId. Single-flight per
|
|
1605
|
+
* session: one polling loop services all of that session's in-flight messages.
|
|
1606
|
+
* A session entry exists while it has any in-flight (dispatched-but-not-done)
|
|
1607
|
+
* message; it is removed once its in-flight set empties.
|
|
1541
1608
|
*/
|
|
1542
1609
|
watchers = /* @__PURE__ */ new Map();
|
|
1610
|
+
/**
|
|
1611
|
+
* AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
|
|
1612
|
+
* dispatched and are still in-flight. A message in this set is never
|
|
1613
|
+
* re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
|
|
1614
|
+
* Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
|
|
1615
|
+
* idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
|
|
1616
|
+
* a steady-state-poll re-dispatch will not double-run the message.
|
|
1617
|
+
*/
|
|
1618
|
+
dispatched = /* @__PURE__ */ new Set();
|
|
1543
1619
|
/**
|
|
1544
1620
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1545
1621
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1562,6 +1638,9 @@ var ChannelDriver = class {
|
|
|
1562
1638
|
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1563
1639
|
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1564
1640
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1641
|
+
this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
|
|
1642
|
+
this.stuckQueuedMs = config2.stuckQueuedMs ?? DEFAULT_STUCK_QUEUED_MS;
|
|
1643
|
+
this.now = config2.now ?? (() => Date.now());
|
|
1565
1644
|
}
|
|
1566
1645
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1567
1646
|
get opencodeBase() {
|
|
@@ -1571,16 +1650,16 @@ var ChannelDriver = class {
|
|
|
1571
1650
|
// Public API
|
|
1572
1651
|
// -------------------------------------------------------------------------
|
|
1573
1652
|
/**
|
|
1574
|
-
* Drain all pending channel conversations once: poll →
|
|
1653
|
+
* Drain all pending channel conversations once: poll → dispatch → register.
|
|
1575
1654
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
1576
1655
|
* Re-entrant calls while a drain is in flight are skipped (return 0).
|
|
1577
1656
|
*
|
|
1578
|
-
* @returns the number of messages
|
|
1657
|
+
* @returns the number of messages NEWLY dispatched to opencode's native queue.
|
|
1579
1658
|
*/
|
|
1580
1659
|
async drainPending() {
|
|
1581
1660
|
if (this.draining) return 0;
|
|
1582
1661
|
this.draining = true;
|
|
1583
|
-
let
|
|
1662
|
+
let dispatched = 0;
|
|
1584
1663
|
try {
|
|
1585
1664
|
const conversations = await this.getPendingConversations();
|
|
1586
1665
|
if (conversations.length > 0) {
|
|
@@ -1591,95 +1670,105 @@ var ChannelDriver = class {
|
|
|
1591
1670
|
});
|
|
1592
1671
|
}
|
|
1593
1672
|
for (const conv of conversations) {
|
|
1594
|
-
|
|
1673
|
+
dispatched += await this.processConversation(conv);
|
|
1595
1674
|
}
|
|
1596
1675
|
} finally {
|
|
1597
1676
|
this.draining = false;
|
|
1598
1677
|
}
|
|
1599
|
-
return
|
|
1678
|
+
return dispatched;
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* True while any per-session watcher has a non-empty in-flight dispatched set
|
|
1682
|
+
* (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
|
|
1683
|
+
* the process while a dispatched message is still queued/running — which would
|
|
1684
|
+
* kill the turn and orphan its reply.
|
|
1685
|
+
*/
|
|
1686
|
+
hasInFlightWatchers() {
|
|
1687
|
+
for (const watcher of this.watchers.values()) {
|
|
1688
|
+
if (watcher.inFlight.size > 0) return true;
|
|
1689
|
+
}
|
|
1690
|
+
return false;
|
|
1600
1691
|
}
|
|
1601
1692
|
/**
|
|
1602
|
-
* Await all outstanding
|
|
1693
|
+
* Await all outstanding per-session watchers (WI-3).
|
|
1603
1694
|
*
|
|
1604
|
-
* In production the
|
|
1605
|
-
* loop never blocks on them and process exit is not held up (the cron
|
|
1606
|
-
* any abandoned ones). This helper exists primarily for deterministic
|
|
1607
|
-
* that need to observe
|
|
1608
|
-
* after a non-blocking `drainPending`.
|
|
1695
|
+
* In production the watcher loops are deliberately started-not-awaited so the
|
|
1696
|
+
* drain loop never blocks on them and process exit is not held up (the cron
|
|
1697
|
+
* recovers any abandoned ones). This helper exists primarily for deterministic
|
|
1698
|
+
* tests that need to observe a watcher's effect (the `processing`/`done` PATCH
|
|
1699
|
+
* or its giving up) after a non-blocking `drainPending`. Watcher loops never
|
|
1700
|
+
* reject, so this resolves.
|
|
1609
1701
|
*/
|
|
1610
1702
|
async flushPausedWatchers() {
|
|
1611
|
-
|
|
1703
|
+
while (true) {
|
|
1704
|
+
const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
|
|
1705
|
+
if (loops.length === 0) return;
|
|
1706
|
+
await Promise.all(loops);
|
|
1707
|
+
const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
|
|
1708
|
+
if (!stillLive) return;
|
|
1709
|
+
}
|
|
1612
1710
|
}
|
|
1613
1711
|
// -------------------------------------------------------------------------
|
|
1614
|
-
// Conversation processing
|
|
1712
|
+
// Conversation processing (WI-3 — async dispatch)
|
|
1615
1713
|
// -------------------------------------------------------------------------
|
|
1714
|
+
/**
|
|
1715
|
+
* Dispatch each pending message for a conversation to opencode's native queue
|
|
1716
|
+
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
1717
|
+
* per-session watcher. Does NOT block on the turn and does NOT call
|
|
1718
|
+
* `markProcessing` here — that fires from the watcher on running-start.
|
|
1719
|
+
*
|
|
1720
|
+
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
1721
|
+
*/
|
|
1616
1722
|
async processConversation(conv) {
|
|
1617
1723
|
const sessionId = await this.ensureSession(conv);
|
|
1618
1724
|
const messages = await this.getPendingMessages(conv.id);
|
|
1619
|
-
let
|
|
1725
|
+
let dispatched = 0;
|
|
1726
|
+
let skippedAlreadyDispatched = 0;
|
|
1620
1727
|
for (const message of messages) {
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
this.log({
|
|
1624
|
-
level: "info",
|
|
1625
|
-
message: `Message ${message.id.slice(0, 8)} already claimed \u2014 skipping`,
|
|
1626
|
-
conversation_id: conv.id,
|
|
1627
|
-
message_id: message.id
|
|
1628
|
-
});
|
|
1728
|
+
if (this.dispatched.has(message.id)) {
|
|
1729
|
+
skippedAlreadyDispatched += 1;
|
|
1629
1730
|
continue;
|
|
1630
1731
|
}
|
|
1732
|
+
const opencodeMessageId = opencodeMessageIdFor2(message.id);
|
|
1733
|
+
const options = {
|
|
1734
|
+
agent: message.opencode_agent ?? void 0,
|
|
1735
|
+
model: message.opencode_model ?? void 0
|
|
1736
|
+
};
|
|
1631
1737
|
try {
|
|
1632
1738
|
this.log({
|
|
1633
1739
|
level: "info",
|
|
1634
|
-
message: `
|
|
1635
|
-
conversation_id: conv.id,
|
|
1636
|
-
message_id: message.id
|
|
1637
|
-
});
|
|
1638
|
-
const result = await sendMessageToOpenCode(
|
|
1639
|
-
this.port,
|
|
1640
|
-
sessionId,
|
|
1641
|
-
message.content,
|
|
1642
|
-
{
|
|
1643
|
-
agent: message.opencode_agent ?? void 0,
|
|
1644
|
-
model: message.opencode_model ?? void 0
|
|
1645
|
-
},
|
|
1646
|
-
{
|
|
1647
|
-
onQuestion: (question) => this.reportInteraction(conv.id, "question", question),
|
|
1648
|
-
onPermission: (permission) => this.reportInteraction(conv.id, "permission", permission)
|
|
1649
|
-
}
|
|
1650
|
-
);
|
|
1651
|
-
if (result.awaitingInteraction) {
|
|
1652
|
-
this.log({
|
|
1653
|
-
level: "info",
|
|
1654
|
-
message: `Message ${message.id.slice(0, 8)} paused awaiting interaction \u2014 watching session for completion`,
|
|
1655
|
-
conversation_id: conv.id,
|
|
1656
|
-
message_id: message.id
|
|
1657
|
-
});
|
|
1658
|
-
this.startPausedWatcher(conv, message, sessionId);
|
|
1659
|
-
continue;
|
|
1660
|
-
}
|
|
1661
|
-
await this.confirmCompletion(sessionId);
|
|
1662
|
-
await this.markDone(conv.id, message.id, sessionId);
|
|
1663
|
-
processed += 1;
|
|
1664
|
-
this.log({
|
|
1665
|
-
level: "info",
|
|
1666
|
-
message: `Message ${message.id.slice(0, 8)} processed`,
|
|
1740
|
+
message: `Dispatching message ${message.id.slice(0, 8)} to OpenCode native queue (session ${sessionId.slice(0, 8)})`,
|
|
1667
1741
|
conversation_id: conv.id,
|
|
1668
1742
|
message_id: message.id
|
|
1669
1743
|
});
|
|
1744
|
+
await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
|
|
1670
1745
|
} catch (err) {
|
|
1671
1746
|
if (err instanceof ChannelAuthError) throw err;
|
|
1747
|
+
this.dispatched.delete(message.id);
|
|
1672
1748
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1673
1749
|
});
|
|
1674
1750
|
this.log({
|
|
1675
1751
|
level: "error",
|
|
1676
|
-
message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1752
|
+
message: `Message ${message.id.slice(0, 8)} dispatch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1677
1753
|
conversation_id: conv.id,
|
|
1678
1754
|
message_id: message.id
|
|
1679
1755
|
});
|
|
1756
|
+
continue;
|
|
1680
1757
|
}
|
|
1758
|
+
this.dispatched.add(message.id);
|
|
1759
|
+
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
1760
|
+
dispatched += 1;
|
|
1761
|
+
void this.postSignal(conv.id, message.id, "dispatched");
|
|
1681
1762
|
}
|
|
1682
|
-
|
|
1763
|
+
if (messages.length > 0 && dispatched === 0 && skippedAlreadyDispatched === messages.length) {
|
|
1764
|
+
this.log({
|
|
1765
|
+
level: "error",
|
|
1766
|
+
message: `Conversation ${conv.id.slice(0, 8)} has ${messages.length} pending message(s) but ALL are already marked dispatched locally (in-flight set: ${this.dispatched.size}) \u2014 none sent to OpenCode this tick. If this repeats, a message may be stuck acknowledged-but-never-dispatched (its watcher never settled).`,
|
|
1767
|
+
conversation_id: conv.id
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
this.ensureWatcherRunning(sessionId);
|
|
1771
|
+
return dispatched;
|
|
1683
1772
|
}
|
|
1684
1773
|
async ensureSession(conv) {
|
|
1685
1774
|
const cached = this.sessions.get(conv.id);
|
|
@@ -1711,111 +1800,356 @@ var ChannelDriver = class {
|
|
|
1711
1800
|
}
|
|
1712
1801
|
return this.opencodeDirectory;
|
|
1713
1802
|
}
|
|
1714
|
-
/**
|
|
1715
|
-
* Local reconcile: re-query `GET /session/:id/message` and check whether the
|
|
1716
|
-
* last assistant message is message-level complete (`isTurnComplete`).
|
|
1717
|
-
*
|
|
1718
|
-
* This is a PURE OBSERVABILITY probe on the normal (non-paused) path: the
|
|
1719
|
-
* blocking POST already returned, and `markDone` causes the server to re-fetch
|
|
1720
|
-
* the messages itself (via `extractTextFromMessages`) when delivering the
|
|
1721
|
-
* reply — so this round-trip never gates delivery. We keep it only to surface a
|
|
1722
|
-
* truthful diagnostic when opencode hasn't yet recorded a completed assistant
|
|
1723
|
-
* turn at reconcile time, then proceed to `markDone` regardless. Uses the
|
|
1724
|
-
* injected `fetchImpl` and reuses only the pure `isTurnComplete` predicate.
|
|
1725
|
-
*/
|
|
1726
|
-
async confirmCompletion(sessionId) {
|
|
1727
|
-
try {
|
|
1728
|
-
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1729
|
-
if (!res.ok) return;
|
|
1730
|
-
const body = await res.json();
|
|
1731
|
-
const messages = Array.isArray(body) ? body : null;
|
|
1732
|
-
if (!isTurnComplete(messages)) {
|
|
1733
|
-
this.log({
|
|
1734
|
-
level: "info",
|
|
1735
|
-
message: `Session ${sessionId.slice(0, 8)} messages do not yet show a completed assistant turn on reconcile \u2014 delivering anyway`
|
|
1736
|
-
});
|
|
1737
|
-
}
|
|
1738
|
-
} catch {
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
1803
|
// -------------------------------------------------------------------------
|
|
1742
|
-
//
|
|
1804
|
+
// Per-session watcher (WI-3)
|
|
1743
1805
|
// -------------------------------------------------------------------------
|
|
1806
|
+
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
1807
|
+
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
1808
|
+
let watcher = this.watchers.get(sessionId);
|
|
1809
|
+
if (!watcher) {
|
|
1810
|
+
watcher = {
|
|
1811
|
+
conv,
|
|
1812
|
+
inFlight: /* @__PURE__ */ new Map(),
|
|
1813
|
+
loop: null,
|
|
1814
|
+
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1815
|
+
reportedPermissions: /* @__PURE__ */ new Set()
|
|
1816
|
+
};
|
|
1817
|
+
this.watchers.set(sessionId, watcher);
|
|
1818
|
+
}
|
|
1819
|
+
const now = this.now();
|
|
1820
|
+
watcher.inFlight.set(message.id, {
|
|
1821
|
+
evidentMessageId: message.id,
|
|
1822
|
+
opencodeMessageId,
|
|
1823
|
+
message,
|
|
1824
|
+
dispatchedAt: now,
|
|
1825
|
+
deadline: now + this.pausedMaxWaitMs,
|
|
1826
|
+
started: false,
|
|
1827
|
+
done: false,
|
|
1828
|
+
stuckReported: false
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1744
1831
|
/**
|
|
1745
|
-
* Start (but do NOT await)
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
* poll/markDone can never crash the run loop — the cron stays as the safety net.
|
|
1832
|
+
* Start (but do NOT await) the per-session watcher loop if it has in-flight
|
|
1833
|
+
* work and is not already running. Single-flight per session. The loop is
|
|
1834
|
+
* tracked on the watcher and cleared when it settles; it never rejects (fully
|
|
1835
|
+
* guarded), so a failed poll/callback can never crash the run loop — the cron
|
|
1836
|
+
* stays as the safety net.
|
|
1751
1837
|
*/
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1838
|
+
ensureWatcherRunning(sessionId) {
|
|
1839
|
+
const watcher = this.watchers.get(sessionId);
|
|
1840
|
+
if (!watcher) return;
|
|
1841
|
+
if (watcher.loop) return;
|
|
1842
|
+
if (watcher.inFlight.size === 0) {
|
|
1843
|
+
this.watchers.delete(sessionId);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
|
|
1847
|
+
watcher.loop = null;
|
|
1848
|
+
if (watcher.inFlight.size === 0) {
|
|
1849
|
+
this.watchers.delete(sessionId);
|
|
1850
|
+
}
|
|
1756
1851
|
});
|
|
1757
|
-
|
|
1852
|
+
watcher.loop = loop;
|
|
1758
1853
|
}
|
|
1759
1854
|
/**
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
* `
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
1765
|
-
* re-
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
* `fetch`) here.
|
|
1772
|
-
*
|
|
1773
|
-
* Bounded by `pausedMaxWaitMs` (default 10 min, strictly < the 15-min cron
|
|
1774
|
-
* reset): on timeout we STOP and leave the message `processing` so the cron
|
|
1775
|
-
* remains the last-resort safety net. The whole body is wrapped so any
|
|
1776
|
-
* poll/markDone failure is logged and swallowed — a watcher MUST NEVER throw
|
|
1777
|
-
* out of the run loop.
|
|
1855
|
+
* The per-session polling loop (WI-3). Once per tick it:
|
|
1856
|
+
* 1. polls `GET /session/:id/message` once and, per in-flight message,
|
|
1857
|
+
* computes `messageRunState` and fires markProcessing (queued→running) /
|
|
1858
|
+
* markDone (done) exactly once per transition;
|
|
1859
|
+
* 2. applies the idle-path re-dispatch guard (a dispatched message that never
|
|
1860
|
+
* APPEARS → re-dispatch — D1 obligation 2);
|
|
1861
|
+
* 3. polls `/question` + `/permission` (scoped to the session) and surfaces
|
|
1862
|
+
* NEW ones via `reportInteraction`, carrying the PAUSED message's own
|
|
1863
|
+
* `source_message_id`;
|
|
1864
|
+
* 4. drops messages that completed or timed out from the in-flight set.
|
|
1865
|
+
* Exits when the in-flight set empties. Never throws.
|
|
1778
1866
|
*/
|
|
1779
|
-
async
|
|
1780
|
-
const deadline = Date.now() + this.pausedMaxWaitMs;
|
|
1867
|
+
async runWatcherLoop(sessionId, watcher) {
|
|
1781
1868
|
try {
|
|
1782
|
-
while (
|
|
1869
|
+
while (watcher.inFlight.size > 0) {
|
|
1783
1870
|
await this.sleep(this.pausedPollIntervalMs);
|
|
1784
|
-
let
|
|
1871
|
+
let messages = null;
|
|
1785
1872
|
try {
|
|
1786
1873
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1787
1874
|
if (res.ok) {
|
|
1788
1875
|
const body = await res.json();
|
|
1789
|
-
|
|
1790
|
-
completed = isTurnComplete(messages);
|
|
1876
|
+
messages = Array.isArray(body) ? body : null;
|
|
1791
1877
|
}
|
|
1792
1878
|
} catch {
|
|
1793
1879
|
continue;
|
|
1794
1880
|
}
|
|
1795
|
-
|
|
1881
|
+
for (const inFlight of [...watcher.inFlight.values()]) {
|
|
1882
|
+
await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
|
|
1883
|
+
}
|
|
1884
|
+
await this.pollInteractions(sessionId, watcher, messages);
|
|
1885
|
+
}
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
if (err instanceof ChannelAuthError) {
|
|
1796
1888
|
this.log({
|
|
1797
|
-
level: "
|
|
1798
|
-
message: `
|
|
1889
|
+
level: "error",
|
|
1890
|
+
message: `Session watcher aborted on auth failure for session ${sessionId.slice(0, 8)} \u2014 clearing in-flight state for re-drive after re-auth: ${err.message}`,
|
|
1891
|
+
conversation_id: watcher.conv.id
|
|
1892
|
+
});
|
|
1893
|
+
for (const evidentMessageId of [...watcher.inFlight.keys()]) {
|
|
1894
|
+
this.removeInFlight(watcher, evidentMessageId);
|
|
1895
|
+
}
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
this.log({
|
|
1899
|
+
level: "error",
|
|
1900
|
+
message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1901
|
+
conversation_id: watcher.conv.id
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
1907
|
+
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
1908
|
+
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
1909
|
+
* in-flight set on completion or timeout.
|
|
1910
|
+
*/
|
|
1911
|
+
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
1912
|
+
const conv = watcher.conv;
|
|
1913
|
+
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
1914
|
+
if ((state === "running" || state === "done") && !inFlight.started) {
|
|
1915
|
+
let claimed;
|
|
1916
|
+
try {
|
|
1917
|
+
claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1920
|
+
this.log({
|
|
1921
|
+
level: "error",
|
|
1922
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1799
1923
|
conversation_id: conv.id,
|
|
1800
|
-
message_id:
|
|
1924
|
+
message_id: inFlight.evidentMessageId
|
|
1801
1925
|
});
|
|
1802
|
-
await this.markDone(conv.id, message.id, sessionId);
|
|
1803
1926
|
return;
|
|
1804
1927
|
}
|
|
1928
|
+
inFlight.started = true;
|
|
1929
|
+
if (!claimed) {
|
|
1930
|
+
this.log({
|
|
1931
|
+
level: "info",
|
|
1932
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
1933
|
+
conversation_id: conv.id,
|
|
1934
|
+
message_id: inFlight.evidentMessageId
|
|
1935
|
+
});
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
if (state === "done") {
|
|
1939
|
+
if (!inFlight.done) {
|
|
1940
|
+
this.log({
|
|
1941
|
+
level: "info",
|
|
1942
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} completed \u2014 marking done`,
|
|
1943
|
+
conversation_id: conv.id,
|
|
1944
|
+
message_id: inFlight.evidentMessageId
|
|
1945
|
+
});
|
|
1946
|
+
try {
|
|
1947
|
+
await this.markDone(conv.id, inFlight.evidentMessageId, sessionId);
|
|
1948
|
+
} catch (err) {
|
|
1949
|
+
if (err instanceof ChannelAuthError) throw err;
|
|
1950
|
+
if (err instanceof ChannelTerminalError) {
|
|
1951
|
+
this.log({
|
|
1952
|
+
level: "error",
|
|
1953
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
1954
|
+
conversation_id: conv.id,
|
|
1955
|
+
message_id: inFlight.evidentMessageId
|
|
1956
|
+
});
|
|
1957
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1958
|
+
return;
|
|
1959
|
+
}
|
|
1960
|
+
if (this.now() >= inFlight.deadline) {
|
|
1961
|
+
this.log({
|
|
1962
|
+
level: "error",
|
|
1963
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done within the watch window \u2014 leaving for the cron safety net: ${err instanceof Error ? err.message : String(err)}`,
|
|
1964
|
+
conversation_id: conv.id,
|
|
1965
|
+
message_id: inFlight.evidentMessageId
|
|
1966
|
+
});
|
|
1967
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
this.log({
|
|
1971
|
+
level: "error",
|
|
1972
|
+
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1973
|
+
conversation_id: conv.id,
|
|
1974
|
+
message_id: inFlight.evidentMessageId
|
|
1975
|
+
});
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
inFlight.done = true;
|
|
1979
|
+
}
|
|
1980
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (state === "unknown") {
|
|
1984
|
+
if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
|
|
1985
|
+
await this.redispatchInFlight(sessionId, inFlight);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
if (state === "queued" && !inFlight.started && !inFlight.stuckReported && this.now() - inFlight.dispatchedAt >= this.stuckQueuedMs && !hasRunningAssistantExcept(messages, inFlight.opencodeMessageId)) {
|
|
1989
|
+
inFlight.stuckReported = true;
|
|
1990
|
+
void this.postSignal(conv.id, inFlight.evidentMessageId, "stuck_queued", {
|
|
1991
|
+
stuck_for_ms: this.now() - inFlight.dispatchedAt
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
if (this.now() >= inFlight.deadline) {
|
|
1805
1995
|
this.log({
|
|
1806
1996
|
level: "info",
|
|
1807
|
-
message: `
|
|
1997
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} did not complete within the watch window \u2014 leaving for the cron safety net`,
|
|
1808
1998
|
conversation_id: conv.id,
|
|
1809
|
-
message_id:
|
|
1999
|
+
message_id: inFlight.evidentMessageId
|
|
1810
2000
|
});
|
|
2001
|
+
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
|
|
2006
|
+
* opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
|
|
2007
|
+
* fact 9) — one user message + one reply even if the original DID land. Resets
|
|
2008
|
+
* the dispatch timestamp so the guard doesn't immediately fire again.
|
|
2009
|
+
*/
|
|
2010
|
+
async redispatchInFlight(sessionId, inFlight) {
|
|
2011
|
+
const options = {
|
|
2012
|
+
agent: inFlight.message.opencode_agent ?? void 0,
|
|
2013
|
+
model: inFlight.message.opencode_model ?? void 0
|
|
2014
|
+
};
|
|
2015
|
+
this.log({
|
|
2016
|
+
level: "info",
|
|
2017
|
+
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
|
|
2018
|
+
message_id: inFlight.evidentMessageId
|
|
2019
|
+
});
|
|
2020
|
+
try {
|
|
2021
|
+
await sendPromptAsync(
|
|
2022
|
+
this.port,
|
|
2023
|
+
sessionId,
|
|
2024
|
+
inFlight.message.content,
|
|
2025
|
+
options,
|
|
2026
|
+
inFlight.opencodeMessageId
|
|
2027
|
+
);
|
|
1811
2028
|
} catch (err) {
|
|
1812
2029
|
this.log({
|
|
1813
2030
|
level: "error",
|
|
1814
|
-
message: `
|
|
1815
|
-
|
|
1816
|
-
|
|
2031
|
+
message: `Re-dispatch failed for message ${inFlight.evidentMessageId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2032
|
+
message_id: inFlight.evidentMessageId
|
|
2033
|
+
});
|
|
2034
|
+
}
|
|
2035
|
+
inFlight.dispatchedAt = this.now();
|
|
2036
|
+
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Remove a message from the in-flight set AND the authoritative dispatched
|
|
2039
|
+
* set. Once the in-flight set empties, the watcher loop's `while` guard exits
|
|
2040
|
+
* and its `.finally` removes the session entry from `this.watchers`.
|
|
2041
|
+
*/
|
|
2042
|
+
removeInFlight(watcher, evidentMessageId) {
|
|
2043
|
+
watcher.inFlight.delete(evidentMessageId);
|
|
2044
|
+
this.dispatched.delete(evidentMessageId);
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
|
|
2048
|
+
* via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
|
|
2049
|
+
* `source_message_id` so the server @mentions the correct person under
|
|
2050
|
+
* concurrency. Dedups by interaction id across ticks (reused per-session sets).
|
|
2051
|
+
*
|
|
2052
|
+
* The interaction is attributed to the in-flight message it paused on. opencode
|
|
2053
|
+
* stamps a `messageID` on a permission (and `tool.messageID` on a question) =
|
|
2054
|
+
* the assistant message id, whose `parentID` is the user message id — but the
|
|
2055
|
+
* simplest robust attribution here is: the single in-flight message that is
|
|
2056
|
+
* RUNNING (not done) is the one that paused. With one running message that is
|
|
2057
|
+
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
2058
|
+
* oldest running message.
|
|
2059
|
+
*/
|
|
2060
|
+
async pollInteractions(sessionId, watcher, messages) {
|
|
2061
|
+
let questions = [];
|
|
2062
|
+
try {
|
|
2063
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
2064
|
+
if (res.ok) {
|
|
2065
|
+
const body = await res.json();
|
|
2066
|
+
questions = Array.isArray(body) ? body : [];
|
|
2067
|
+
}
|
|
2068
|
+
} catch {
|
|
2069
|
+
}
|
|
2070
|
+
for (const q of questions) {
|
|
2071
|
+
if (q.sessionID !== sessionId) continue;
|
|
2072
|
+
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
2073
|
+
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
2074
|
+
const reported = await this.reportInteraction(
|
|
2075
|
+
watcher.conv.id,
|
|
2076
|
+
"question",
|
|
2077
|
+
q,
|
|
2078
|
+
paused?.message.source_message_id ?? void 0
|
|
2079
|
+
);
|
|
2080
|
+
if (reported) watcher.reportedQuestions.add(q.id);
|
|
2081
|
+
}
|
|
2082
|
+
let permissions = [];
|
|
2083
|
+
try {
|
|
2084
|
+
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
2085
|
+
if (res.ok) {
|
|
2086
|
+
const body = await res.json();
|
|
2087
|
+
permissions = Array.isArray(body) ? body : [];
|
|
2088
|
+
}
|
|
2089
|
+
} catch {
|
|
2090
|
+
}
|
|
2091
|
+
for (const p of permissions) {
|
|
2092
|
+
if (p.sessionID !== sessionId) continue;
|
|
2093
|
+
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
2094
|
+
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
2095
|
+
const reported = await this.reportInteraction(
|
|
2096
|
+
watcher.conv.id,
|
|
2097
|
+
"permission",
|
|
2098
|
+
p,
|
|
2099
|
+
paused?.message.source_message_id ?? void 0
|
|
2100
|
+
);
|
|
2101
|
+
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
2106
|
+
*
|
|
2107
|
+
* The interaction carries `interactionMessageId` — the ASSISTANT message id
|
|
2108
|
+
* that raised it (a question's `tool.messageID` / a permission's `messageID`).
|
|
2109
|
+
* That assistant message is the reply to ONE of our minted user messages
|
|
2110
|
+
* (correlated by `parentID`, GATE-B). So when we have the tick's message
|
|
2111
|
+
* snapshot, we resolve each running in-flight message's correlated assistant
|
|
2112
|
+
* reply (`findAssistantReplyAfter`) and match its id against
|
|
2113
|
+
* `interactionMessageId` — giving an EXACT attribution even with several
|
|
2114
|
+
* messages in flight concurrently in one session.
|
|
2115
|
+
*
|
|
2116
|
+
* We fall back to the oldest running message ONLY when no exact match is
|
|
2117
|
+
* possible (the id is absent, the snapshot is missing, or the reply has not yet
|
|
2118
|
+
* been correlated). With a single running message either path is exact. Never
|
|
2119
|
+
* throws.
|
|
2120
|
+
*
|
|
2121
|
+
* Attribution must NOT depend on our own `started` PATCH flag: opencode can
|
|
2122
|
+
* START a turn AND raise a question/permission BEFORE our next tick fires
|
|
2123
|
+
* `markProcessing` (which sets `started`). Relying on `started` would leave the
|
|
2124
|
+
* running set empty in that window and let the server fall back to "newest
|
|
2125
|
+
* processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
|
|
2126
|
+
* person whose active turn actually paused. So we derive "running" from the
|
|
2127
|
+
* tick's `messages` snapshot via `messageRunState` instead.
|
|
2128
|
+
*/
|
|
2129
|
+
attributeInteraction(watcher, interactionMessageId, messages) {
|
|
2130
|
+
const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
|
|
2131
|
+
if (inFlight.length === 0) return void 0;
|
|
2132
|
+
if (interactionMessageId && messages) {
|
|
2133
|
+
const exact = inFlight.find((m) => {
|
|
2134
|
+
const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
|
|
2135
|
+
return reply != null && messageIdOf(reply) === interactionMessageId;
|
|
1817
2136
|
});
|
|
2137
|
+
if (exact) return exact;
|
|
2138
|
+
}
|
|
2139
|
+
const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
|
|
2140
|
+
if (messages) {
|
|
2141
|
+
const runningPerSnapshot = inFlight.filter(
|
|
2142
|
+
(m) => messageRunState(messages, m.opencodeMessageId) === "running"
|
|
2143
|
+
);
|
|
2144
|
+
if (runningPerSnapshot.length > 0) {
|
|
2145
|
+
return runningPerSnapshot.sort(byOldest)[0];
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
const startedRunning = inFlight.filter((m) => m.started);
|
|
2149
|
+
if (startedRunning.length > 0) {
|
|
2150
|
+
return startedRunning.sort(byOldest)[0];
|
|
1818
2151
|
}
|
|
2152
|
+
return inFlight.sort(byOldest)[0];
|
|
1819
2153
|
}
|
|
1820
2154
|
// -------------------------------------------------------------------------
|
|
1821
2155
|
// Evident API calls (combinedAuth thread routes)
|
|
@@ -1849,39 +2183,86 @@ var ChannelDriver = class {
|
|
|
1849
2183
|
}
|
|
1850
2184
|
return await res.json();
|
|
1851
2185
|
}
|
|
2186
|
+
/**
|
|
2187
|
+
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
2188
|
+
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
2189
|
+
* opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
|
|
2190
|
+
* deep-linked "View in Evident" notice).
|
|
2191
|
+
*
|
|
2192
|
+
* Return/throw contract (consumed by the watcher's swap-to-running guard):
|
|
2193
|
+
* - returns `true` → the server transitioned the row to processing;
|
|
2194
|
+
* - returns `false` → the server gave a DEFINITIVE "already-processing"
|
|
2195
|
+
* answer (a non-retryable, non-auth status — e.g. a
|
|
2196
|
+
* conflict because a duplicate already transitioned it),
|
|
2197
|
+
* so the caller treats it as already-started and does NOT
|
|
2198
|
+
* retry;
|
|
2199
|
+
* - throws `ChannelAuthError` on 401/403 (terminal auth failure);
|
|
2200
|
+
* - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
|
|
2201
|
+
* network-level error from `fetch`) — i.e. NO definitive server response —
|
|
2202
|
+
* so the caller leaves the message un-started and retries the swap on the
|
|
2203
|
+
* next tick.
|
|
2204
|
+
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2205
|
+
* retry vehicle for the swap-to-running.
|
|
2206
|
+
*/
|
|
1852
2207
|
async markProcessing(conversationId, messageId, sessionId) {
|
|
1853
2208
|
const res = await this.fetchImpl(
|
|
1854
2209
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1855
2210
|
{
|
|
1856
2211
|
method: "PATCH",
|
|
1857
2212
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1858
|
-
// Include the opencode session id so the server can deep-link the
|
|
1859
|
-
// "View in Evident" notice straight to the conversation (the session
|
|
1860
|
-
// already exists by now — ensureSession runs before claiming).
|
|
1861
2213
|
body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
|
|
1862
2214
|
}
|
|
1863
2215
|
);
|
|
1864
2216
|
this.assertAuth(res, "marking message as processing");
|
|
1865
|
-
|
|
2217
|
+
if (res.ok) return true;
|
|
2218
|
+
if (isRetryableStatus(res.status)) {
|
|
2219
|
+
throw new Error(`marking message as processing: HTTP ${res.status}`);
|
|
2220
|
+
}
|
|
2221
|
+
return false;
|
|
1866
2222
|
}
|
|
1867
2223
|
/**
|
|
1868
|
-
* EXISTING combinedAuth completion route — idempotent
|
|
1869
|
-
*
|
|
2224
|
+
* EXISTING combinedAuth completion route — idempotent (WI-CHAN-2). `PATCH
|
|
2225
|
+
* .../messages/:id {status:'done', opencode_session_id}`. The server's
|
|
1870
2226
|
* `queued_conversation_messages.status`/`processed_at` gate makes a re-call
|
|
1871
|
-
* for an already-`done` message a no-op (no double Slack post).
|
|
2227
|
+
* for an already-`done` message a no-op (no double Slack post). Fired by the
|
|
2228
|
+
* watcher on per-message completion (Task 3.4) — no `confirmCompletion`
|
|
2229
|
+
* round-trip (we already observed completion via the message list).
|
|
2230
|
+
*
|
|
2231
|
+
* SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
|
|
2232
|
+
* services its in-flight messages SEQUENTIALLY within a tick
|
|
2233
|
+
* (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
|
|
2234
|
+
* backoff here would BLOCK sibling messages in the SAME session/tick: while
|
|
2235
|
+
* message A's done PATCH burned its internal retries, message B could not be
|
|
2236
|
+
* swapped to running even though opencode had already started it. Instead this
|
|
2237
|
+
* does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
|
|
2238
|
+
* handler already relies on, leaning on the per-tick retry across ticks
|
|
2239
|
+
* (bounded by `inFlight.deadline`) rather than an in-call retry:
|
|
2240
|
+
* - resolves (`void`) → the server transitioned the row to done
|
|
2241
|
+
* (or idempotently confirmed already-done);
|
|
2242
|
+
* - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
|
|
2243
|
+
* cleanup, Finding 1);
|
|
2244
|
+
* - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
|
|
2245
|
+
* succeed → straight to the cron, Finding 4);
|
|
2246
|
+
* - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
|
|
2247
|
+
* (no definitive server response → the
|
|
2248
|
+
* watcher retries next tick within the
|
|
2249
|
+
* deadline, Finding 4).
|
|
1872
2250
|
*/
|
|
1873
2251
|
async markDone(conversationId, messageId, sessionId) {
|
|
1874
|
-
await this.
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
{
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
|
|
1882
|
-
}
|
|
1883
|
-
)
|
|
2252
|
+
const res = await this.fetchImpl(
|
|
2253
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2254
|
+
{
|
|
2255
|
+
method: "PATCH",
|
|
2256
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2257
|
+
body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
|
|
2258
|
+
}
|
|
1884
2259
|
);
|
|
2260
|
+
this.assertAuth(res, "marking message as done");
|
|
2261
|
+
if (res.ok) return;
|
|
2262
|
+
if (isRetryableStatus(res.status)) {
|
|
2263
|
+
throw new Error(`marking message as done: HTTP ${res.status}`);
|
|
2264
|
+
}
|
|
2265
|
+
throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
|
|
1885
2266
|
}
|
|
1886
2267
|
async markFailed(conversationId, messageId) {
|
|
1887
2268
|
await this.callWithRetry(
|
|
@@ -1896,6 +2277,42 @@ var ChannelDriver = class {
|
|
|
1896
2277
|
)
|
|
1897
2278
|
);
|
|
1898
2279
|
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Best-effort, SINGLE-ATTEMPT runner→server telemetry ping
|
|
2282
|
+
* (queued-followup-redrive). `POST .../messages/:id/signal {signal, ...extra}`
|
|
2283
|
+
* — the server records it via `log()` (no DB write, no notification). This is
|
|
2284
|
+
* fire-and-forget: it MUST NEVER throw into the drain or the watcher tick, and
|
|
2285
|
+
* MUST NOT use `callWithRetry` (a telemetry ping must not block the sequential
|
|
2286
|
+
* watcher tick — one attempt is enough). A failure is SWALLOWED but LOGGED with
|
|
2287
|
+
* context (no silent catch, per development-workflow).
|
|
2288
|
+
*/
|
|
2289
|
+
async postSignal(conversationId, messageId, signal, extra) {
|
|
2290
|
+
try {
|
|
2291
|
+
const res = await this.fetchImpl(
|
|
2292
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}/signal`,
|
|
2293
|
+
{
|
|
2294
|
+
method: "POST",
|
|
2295
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2296
|
+
body: JSON.stringify({ signal, ...extra })
|
|
2297
|
+
}
|
|
2298
|
+
);
|
|
2299
|
+
if (!res.ok) {
|
|
2300
|
+
this.log({
|
|
2301
|
+
level: "error",
|
|
2302
|
+
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} returned HTTP ${res.status} (telemetry-only, ignored)`,
|
|
2303
|
+
conversation_id: conversationId,
|
|
2304
|
+
message_id: messageId
|
|
2305
|
+
});
|
|
2306
|
+
}
|
|
2307
|
+
} catch (err) {
|
|
2308
|
+
this.log({
|
|
2309
|
+
level: "error",
|
|
2310
|
+
message: `Signal '${signal}' for message ${messageId.slice(0, 8)} failed (telemetry-only, ignored): ${err instanceof Error ? err.message : String(err)}`,
|
|
2311
|
+
conversation_id: conversationId,
|
|
2312
|
+
message_id: messageId
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
1899
2316
|
async persistSession(conversationId, sessionId) {
|
|
1900
2317
|
const res = await this.fetchImpl(
|
|
1901
2318
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}`,
|
|
@@ -1909,10 +2326,17 @@ var ChannelDriver = class {
|
|
|
1909
2326
|
}
|
|
1910
2327
|
/**
|
|
1911
2328
|
* EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
|
|
1912
|
-
* `POST .../interactive-event {type, data}`. The server
|
|
1913
|
-
* interaction and posts a link to the proxied opencode-web
|
|
2329
|
+
* `POST .../interactive-event {type, data, source_message_id?}`. The server
|
|
2330
|
+
* persists the interaction and posts a link to the proxied opencode-web
|
|
2331
|
+
* conversation, @mentioning the user who triggered THIS message's turn.
|
|
2332
|
+
*
|
|
2333
|
+
* WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
|
|
2334
|
+
* ts (`message.source_message_id`). The server resolves the @mention from that
|
|
2335
|
+
* message's user FIRST (falling back to the old "newest processing" precedence
|
|
2336
|
+
* only when absent), so the correct person is mentioned under concurrency. It
|
|
2337
|
+
* is OPTIONAL for back-compat with older clients / legacy rows.
|
|
1914
2338
|
*/
|
|
1915
|
-
async reportInteraction(conversationId, type, data) {
|
|
2339
|
+
async reportInteraction(conversationId, type, data, sourceMessageId) {
|
|
1916
2340
|
try {
|
|
1917
2341
|
await this.callWithRetry(
|
|
1918
2342
|
"reporting interactive event",
|
|
@@ -1921,7 +2345,9 @@ var ChannelDriver = class {
|
|
|
1921
2345
|
{
|
|
1922
2346
|
method: "POST",
|
|
1923
2347
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1924
|
-
body: JSON.stringify(
|
|
2348
|
+
body: JSON.stringify(
|
|
2349
|
+
sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
|
|
2350
|
+
)
|
|
1925
2351
|
}
|
|
1926
2352
|
)
|
|
1927
2353
|
);
|
|
@@ -1930,6 +2356,7 @@ var ChannelDriver = class {
|
|
|
1930
2356
|
message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
|
|
1931
2357
|
conversation_id: conversationId
|
|
1932
2358
|
});
|
|
2359
|
+
return true;
|
|
1933
2360
|
} catch (err) {
|
|
1934
2361
|
if (err instanceof ChannelAuthError) throw err;
|
|
1935
2362
|
this.log({
|
|
@@ -1937,6 +2364,7 @@ var ChannelDriver = class {
|
|
|
1937
2364
|
message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1938
2365
|
conversation_id: conversationId
|
|
1939
2366
|
});
|
|
2367
|
+
return false;
|
|
1940
2368
|
}
|
|
1941
2369
|
}
|
|
1942
2370
|
// -------------------------------------------------------------------------
|
|
@@ -1975,8 +2403,9 @@ var ChannelDriver = class {
|
|
|
1975
2403
|
await this.sleep(backoffDelay(attempt, this.retry));
|
|
1976
2404
|
continue;
|
|
1977
2405
|
}
|
|
2406
|
+
break;
|
|
1978
2407
|
}
|
|
1979
|
-
throw new
|
|
2408
|
+
throw new ChannelTerminalError(`${context}: HTTP ${res.status}`, res.status);
|
|
1980
2409
|
}
|
|
1981
2410
|
throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
|
|
1982
2411
|
}
|
|
@@ -2199,7 +2628,7 @@ async function getAgentInfo(agentId, authHeader) {
|
|
|
2199
2628
|
// src/commands/run.ts
|
|
2200
2629
|
var MAX_ACTIVITY_LOG_ENTRIES = 10;
|
|
2201
2630
|
var CHANNEL_POLL_INTERVAL_MS = Number(process.env.EVIDENT_CHANNEL_POLL_INTERVAL_MS) || 2e3;
|
|
2202
|
-
function
|
|
2631
|
+
function log2(state, message, isError = false) {
|
|
2203
2632
|
if (state.json) {
|
|
2204
2633
|
console.log(
|
|
2205
2634
|
JSON.stringify({
|
|
@@ -2224,9 +2653,9 @@ function logActivity(state, entry) {
|
|
|
2224
2653
|
}
|
|
2225
2654
|
if (!state.interactive) {
|
|
2226
2655
|
if (entry.type === "error") {
|
|
2227
|
-
|
|
2656
|
+
log2(state, entry.error ?? "Unknown error", true);
|
|
2228
2657
|
} else if (entry.type === "info" && entry.message) {
|
|
2229
|
-
|
|
2658
|
+
log2(state, entry.message);
|
|
2230
2659
|
}
|
|
2231
2660
|
}
|
|
2232
2661
|
}
|
|
@@ -2312,6 +2741,7 @@ async function handleAuthError(state, error2) {
|
|
|
2312
2741
|
}
|
|
2313
2742
|
async function driveChannels(state, driver) {
|
|
2314
2743
|
let idlePolls = 0;
|
|
2744
|
+
let lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
2315
2745
|
while (state.running) {
|
|
2316
2746
|
if (state.connection?.reconnecting && state.connection.reconnectPromise) {
|
|
2317
2747
|
logActivity(state, { type: "info", message: "Waiting for tunnel reconnection..." });
|
|
@@ -2321,9 +2751,11 @@ async function driveChannels(state, driver) {
|
|
|
2321
2751
|
try {
|
|
2322
2752
|
const processed = await driver.drainPending();
|
|
2323
2753
|
state.messageCount += processed;
|
|
2324
|
-
|
|
2754
|
+
const proxiedActivity = state.lastProxiedActivityAt !== lastSeenProxiedActivityAt;
|
|
2755
|
+
lastSeenProxiedActivityAt = state.lastProxiedActivityAt;
|
|
2756
|
+
if (processed > 0 || driver.hasInFlightWatchers() || proxiedActivity) {
|
|
2325
2757
|
idlePolls = 0;
|
|
2326
|
-
if (state.interactive) displayStatus(state);
|
|
2758
|
+
if (processed > 0 && state.interactive) displayStatus(state);
|
|
2327
2759
|
} else if (state.idleTimeout !== null) {
|
|
2328
2760
|
idlePolls++;
|
|
2329
2761
|
if (idlePolls === 1) {
|
|
@@ -2373,7 +2805,7 @@ async function cleanup(state) {
|
|
|
2373
2805
|
logActivity(state, { type: "info", message: "Stopped OpenCode process" });
|
|
2374
2806
|
displayStatus(state);
|
|
2375
2807
|
} else {
|
|
2376
|
-
|
|
2808
|
+
log2(state, "Stopped OpenCode process");
|
|
2377
2809
|
}
|
|
2378
2810
|
state.opencodeProcess = null;
|
|
2379
2811
|
}
|
|
@@ -2396,10 +2828,11 @@ async function run(options) {
|
|
|
2396
2828
|
running: true,
|
|
2397
2829
|
activityLog: [],
|
|
2398
2830
|
messageCount: 0,
|
|
2831
|
+
lastProxiedActivityAt: null,
|
|
2399
2832
|
authHeader: ""
|
|
2400
2833
|
};
|
|
2401
2834
|
if (state.idleTimeout === null && (process.env.GITHUB_ACTIONS || process.env.CI)) {
|
|
2402
|
-
|
|
2835
|
+
log2(
|
|
2403
2836
|
state,
|
|
2404
2837
|
"Warning: No --idle-timeout set in CI environment. The runner will poll indefinitely until the job times out. Consider adding --idle-timeout 30 to avoid wasting runner minutes.",
|
|
2405
2838
|
false
|
|
@@ -2410,7 +2843,7 @@ async function run(options) {
|
|
|
2410
2843
|
logActivity(state, { type: "info", message: "Shutting down..." });
|
|
2411
2844
|
displayStatus(state);
|
|
2412
2845
|
} else {
|
|
2413
|
-
|
|
2846
|
+
log2(state, "Shutting down...");
|
|
2414
2847
|
}
|
|
2415
2848
|
await cleanup(state);
|
|
2416
2849
|
await shutdownTelemetry();
|
|
@@ -2443,7 +2876,7 @@ async function run(options) {
|
|
|
2443
2876
|
const resolved = await resolveAgentIdFromKey(state.authHeader);
|
|
2444
2877
|
if (resolved.agent_id) {
|
|
2445
2878
|
state.agentId = resolved.agent_id;
|
|
2446
|
-
|
|
2879
|
+
log2(state, `Resolved agent ID from key: ${state.agentId}`);
|
|
2447
2880
|
if (state.interactive && !state.json) {
|
|
2448
2881
|
logActivity(state, {
|
|
2449
2882
|
type: "info",
|
|
@@ -2506,14 +2939,21 @@ async function run(options) {
|
|
|
2506
2939
|
port: state.port,
|
|
2507
2940
|
interactive: state.interactive,
|
|
2508
2941
|
agentId: state.agentId,
|
|
2509
|
-
log: (message) =>
|
|
2942
|
+
log: (message) => log2(state, message)
|
|
2510
2943
|
});
|
|
2511
2944
|
state.port = oc.port;
|
|
2512
2945
|
state.opencodeProcess = oc.process;
|
|
2513
2946
|
state.opencodeVersion = oc.version;
|
|
2514
2947
|
state.opencodeConnected = oc.process !== null || oc.version !== null;
|
|
2515
|
-
const
|
|
2516
|
-
ocSpinner?.succeed(`OpenCode running on port ${state.port}${
|
|
2948
|
+
const version2 = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
2949
|
+
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version2}`);
|
|
2950
|
+
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
2951
|
+
if (versionWarning) {
|
|
2952
|
+
log2(state, versionWarning, false);
|
|
2953
|
+
if (state.interactive && !state.json) {
|
|
2954
|
+
logActivity(state, { type: "info", message: versionWarning });
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2517
2957
|
} catch (error2) {
|
|
2518
2958
|
ocSpinner?.fail(error2.message);
|
|
2519
2959
|
throw error2;
|
|
@@ -2578,9 +3018,14 @@ async function run(options) {
|
|
|
2578
3018
|
logActivity(state, { type: "error", error: error2 });
|
|
2579
3019
|
if (state.interactive) displayStatus(state);
|
|
2580
3020
|
},
|
|
2581
|
-
// Web traffic is proxied transparently;
|
|
3021
|
+
// Web traffic is proxied transparently; note opencode is live and stamp
|
|
3022
|
+
// proxied activity so the idle loop treats interactive proxy use as work.
|
|
3023
|
+
// Fires per forwarded response head (incl. every SSE open) and excludes
|
|
3024
|
+
// the internal drain-ping, so an actively-used proxy keeps the timer
|
|
3025
|
+
// fresh while a lone idle SSE with no follow-up requests still ages out.
|
|
2582
3026
|
onResponse: () => {
|
|
2583
3027
|
state.opencodeConnected = true;
|
|
3028
|
+
state.lastProxiedActivityAt = Date.now();
|
|
2584
3029
|
},
|
|
2585
3030
|
// A channel message was queued and the api-worker pinged us over the
|
|
2586
3031
|
// tunnel to drain immediately instead of waiting for the next poll tick.
|
|
@@ -2619,7 +3064,7 @@ async function run(options) {
|
|
|
2619
3064
|
throw error2;
|
|
2620
3065
|
}
|
|
2621
3066
|
if (!interactive || state.json) {
|
|
2622
|
-
|
|
3067
|
+
log2(state, "Driving channel messages...");
|
|
2623
3068
|
}
|
|
2624
3069
|
await driveChannels(state, channelDriver);
|
|
2625
3070
|
await cleanup(state);
|
|
@@ -2631,7 +3076,7 @@ async function run(options) {
|
|
|
2631
3076
|
})
|
|
2632
3077
|
);
|
|
2633
3078
|
} else if (!interactive) {
|
|
2634
|
-
|
|
3079
|
+
log2(state, `Completed. Processed ${state.messageCount} message(s).`);
|
|
2635
3080
|
}
|
|
2636
3081
|
await shutdownTelemetry();
|
|
2637
3082
|
process.exit(0);
|
|
@@ -2653,8 +3098,9 @@ async function run(options) {
|
|
|
2653
3098
|
}
|
|
2654
3099
|
|
|
2655
3100
|
// src/index.ts
|
|
3101
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
2656
3102
|
var program = new Command();
|
|
2657
|
-
program.name("evident").description("Run OpenCode locally and connect it to Evident").version(
|
|
3103
|
+
program.name("evident").description("Run OpenCode locally and connect it to Evident").version(version).option(
|
|
2658
3104
|
"--endpoint <url>",
|
|
2659
3105
|
"Evident API base URL (default: production; e.g. http://localhost:3001)"
|
|
2660
3106
|
).option("--tunnel <url>", "Tunnel WebSocket URL (default: production; e.g. ws://localhost:8787)").hook("preAction", (thisCommand) => {
|