@evident-ai/cli 3.0.1-dev.28a3007 → 3.0.1-dev.3be0a8d
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/README.md +87 -131
- package/dist/index.js +246 -551
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -684,19 +684,6 @@ async function waitForOpenCodeHealth(port, timeoutMs = 3e4) {
|
|
|
684
684
|
return { healthy: false, error: "Timeout waiting for OpenCode to be healthy" };
|
|
685
685
|
}
|
|
686
686
|
|
|
687
|
-
// src/lib/opencode/opencode-version-gate.ts
|
|
688
|
-
var QUEUE_VALIDATED_OPENCODE_VERSIONS = ["1.17.11"];
|
|
689
|
-
function isQueueValidatedVersion(version) {
|
|
690
|
-
if (!version) return false;
|
|
691
|
-
return QUEUE_VALIDATED_OPENCODE_VERSIONS.includes(version);
|
|
692
|
-
}
|
|
693
|
-
function buildOpenCodeVersionWarning(version) {
|
|
694
|
-
if (isQueueValidatedVersion(version)) return null;
|
|
695
|
-
const detected = version ? `v${version}` : "unknown";
|
|
696
|
-
const validated = QUEUE_VALIDATED_OPENCODE_VERSIONS.map((v) => `v${v}`).join(", ");
|
|
697
|
-
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.`;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
687
|
// src/lib/opencode/process.ts
|
|
701
688
|
import { execSync, spawn } from "child_process";
|
|
702
689
|
var OPENCODE_PORT_RANGE = [4096, 4097, 4098, 4099, 4100];
|
|
@@ -1011,17 +998,21 @@ function completedOf(m) {
|
|
|
1011
998
|
if (!m || typeof m !== "object") return void 0;
|
|
1012
999
|
return m.info?.time?.completed;
|
|
1013
1000
|
}
|
|
1014
|
-
function
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1001
|
+
async function getSessionMessages(port, sessionId) {
|
|
1002
|
+
try {
|
|
1003
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`);
|
|
1004
|
+
if (!res.ok) return null;
|
|
1005
|
+
const body = await res.json();
|
|
1006
|
+
return Array.isArray(body) ? body : null;
|
|
1007
|
+
} catch {
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1019
1010
|
}
|
|
1020
|
-
function
|
|
1021
|
-
if (!
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
return
|
|
1011
|
+
function isTurnComplete(messages) {
|
|
1012
|
+
if (!messages || messages.length === 0) return false;
|
|
1013
|
+
const last = messages[messages.length - 1];
|
|
1014
|
+
if (roleOf(last) !== "assistant") return false;
|
|
1015
|
+
return completedOf(last) != null;
|
|
1025
1016
|
}
|
|
1026
1017
|
async function createOpenCodeSession(port, directory) {
|
|
1027
1018
|
const url = new URL(`${opencodeBase(port)}/session`);
|
|
@@ -1040,9 +1031,8 @@ async function createOpenCodeSession(port, directory) {
|
|
|
1040
1031
|
const data = await response.json();
|
|
1041
1032
|
return data.id;
|
|
1042
1033
|
}
|
|
1043
|
-
async function
|
|
1034
|
+
async function sendMessageToOpenCode(port, sessionId, content, options, hooks, maxWaitMs = 10 * 60 * 1e3) {
|
|
1044
1035
|
const body = {
|
|
1045
|
-
messageID: messageId,
|
|
1046
1036
|
parts: [{ type: "text", text: content }]
|
|
1047
1037
|
};
|
|
1048
1038
|
if (options?.agent) {
|
|
@@ -1057,42 +1047,79 @@ async function sendPromptAsync(port, sessionId, content, options, messageId) {
|
|
|
1057
1047
|
};
|
|
1058
1048
|
}
|
|
1059
1049
|
}
|
|
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
|
-
|
|
1050
|
+
let pollDone = false;
|
|
1051
|
+
const reportedQuestions = /* @__PURE__ */ new Set();
|
|
1052
|
+
const reportedPermissions = /* @__PURE__ */ new Set();
|
|
1053
|
+
const pollInteractive = async () => {
|
|
1054
|
+
while (!pollDone) {
|
|
1055
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
1056
|
+
if (pollDone) break;
|
|
1057
|
+
if (hooks?.onQuestion) {
|
|
1058
|
+
try {
|
|
1059
|
+
const res = await fetch(`${opencodeBase(port)}/question`);
|
|
1060
|
+
if (res.ok) {
|
|
1061
|
+
const questions = await res.json();
|
|
1062
|
+
for (const q of questions) {
|
|
1063
|
+
if (q.sessionID === sessionId && !reportedQuestions.has(q.id)) {
|
|
1064
|
+
reportedQuestions.add(q.id);
|
|
1065
|
+
await hooks.onQuestion(q);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
} catch {
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
if (hooks?.onPermission) {
|
|
1073
|
+
try {
|
|
1074
|
+
const res = await fetch(`${opencodeBase(port)}/permission`);
|
|
1075
|
+
if (res.ok) {
|
|
1076
|
+
const permissions = await res.json();
|
|
1077
|
+
for (const p of permissions) {
|
|
1078
|
+
if (p.sessionID === sessionId && !reportedPermissions.has(p.id)) {
|
|
1079
|
+
reportedPermissions.add(p.id);
|
|
1080
|
+
await hooks.onPermission(p);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
} catch {
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
};
|
|
1089
|
+
const sendMessage = async () => {
|
|
1090
|
+
const controller = new AbortController();
|
|
1091
|
+
const timer = setTimeout(() => controller.abort(), maxWaitMs);
|
|
1092
|
+
try {
|
|
1093
|
+
const res = await fetch(`${opencodeBase(port)}/session/${sessionId}/message`, {
|
|
1094
|
+
method: "POST",
|
|
1095
|
+
headers: { "Content-Type": "application/json" },
|
|
1096
|
+
body: JSON.stringify(body),
|
|
1097
|
+
signal: controller.signal
|
|
1098
|
+
});
|
|
1099
|
+
if (!res.ok) {
|
|
1100
|
+
const text = await res.text().catch(() => "");
|
|
1101
|
+
throw new Error(`OpenCode message failed: HTTP ${res.status}${text ? `: ${text}` : ""}`);
|
|
1102
|
+
}
|
|
1103
|
+
const sessionRes = await fetch(`${opencodeBase(port)}/session/${sessionId}`).catch(
|
|
1104
|
+
() => null
|
|
1105
|
+
);
|
|
1106
|
+
const session = sessionRes?.ok ? await sessionRes.json() : null;
|
|
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;
|
|
1096
1123
|
}
|
|
1097
1124
|
|
|
1098
1125
|
// src/lib/tunnel/connection.ts
|
|
@@ -1471,12 +1498,6 @@ var RunnerConnection = class {
|
|
|
1471
1498
|
};
|
|
1472
1499
|
|
|
1473
1500
|
// src/lib/channels/driver.ts
|
|
1474
|
-
function messageIdOf(m) {
|
|
1475
|
-
if (!m || typeof m !== "object") return void 0;
|
|
1476
|
-
if (typeof m.id === "string") return m.id;
|
|
1477
|
-
const infoId = m.info?.id;
|
|
1478
|
-
return typeof infoId === "string" ? infoId : void 0;
|
|
1479
|
-
}
|
|
1480
1501
|
var DEFAULT_RETRY_POLICY = {
|
|
1481
1502
|
maxAttempts: 6,
|
|
1482
1503
|
baseDelayMs: 500,
|
|
@@ -1484,21 +1505,12 @@ var DEFAULT_RETRY_POLICY = {
|
|
|
1484
1505
|
};
|
|
1485
1506
|
var DEFAULT_PAUSED_POLL_INTERVAL_MS = 2e3;
|
|
1486
1507
|
var DEFAULT_PAUSED_MAX_WAIT_MS = 10 * 60 * 1e3;
|
|
1487
|
-
var DEFAULT_DISPATCH_CONFIRM_MS = 6e3;
|
|
1488
1508
|
var ChannelAuthError = class extends Error {
|
|
1489
1509
|
constructor(message) {
|
|
1490
1510
|
super(message);
|
|
1491
1511
|
this.name = "ChannelAuthError";
|
|
1492
1512
|
}
|
|
1493
1513
|
};
|
|
1494
|
-
var ChannelTerminalError = class extends Error {
|
|
1495
|
-
status;
|
|
1496
|
-
constructor(message, status) {
|
|
1497
|
-
super(message);
|
|
1498
|
-
this.name = "ChannelTerminalError";
|
|
1499
|
-
this.status = status;
|
|
1500
|
-
}
|
|
1501
|
-
};
|
|
1502
1514
|
function backoffDelay(attempt, policy) {
|
|
1503
1515
|
const exp = policy.baseDelayMs * Math.pow(2, attempt);
|
|
1504
1516
|
const capped = Math.min(policy.maxDelayMs, exp);
|
|
@@ -1519,26 +1531,15 @@ var ChannelDriver = class {
|
|
|
1519
1531
|
sleep;
|
|
1520
1532
|
pausedPollIntervalMs;
|
|
1521
1533
|
pausedMaxWaitMs;
|
|
1522
|
-
dispatchConfirmMs;
|
|
1523
|
-
now;
|
|
1524
1534
|
/** Cache of conversationId → opencode sessionId. */
|
|
1525
1535
|
sessions = /* @__PURE__ */ new Map();
|
|
1526
1536
|
/**
|
|
1527
|
-
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
1537
|
+
* Outstanding paused-session watchers, keyed by `message.id` (WI-2-CLI).
|
|
1538
|
+
* Single-flight per message: while a watcher is live for a message we never
|
|
1539
|
+
* start a second one. The message stays `processing` for the watcher's lifetime
|
|
1540
|
+
* so the `?status=pending` drain cannot double-claim it.
|
|
1531
1541
|
*/
|
|
1532
1542
|
watchers = /* @__PURE__ */ new Map();
|
|
1533
|
-
/**
|
|
1534
|
-
* AUTHORITATIVE local dedup (WI-3): Evident message ids that have been
|
|
1535
|
-
* dispatched and are still in-flight. A message in this set is never
|
|
1536
|
-
* re-`prompt_async`-ed by a subsequent poll tick while it is queued/running.
|
|
1537
|
-
* Backed by a stable minted opencode `messageID` whose duplicate re-enqueue is
|
|
1538
|
-
* idempotent on opencode (PoC fact 9) — so even if this set is lost on restart,
|
|
1539
|
-
* a steady-state-poll re-dispatch will not double-run the message.
|
|
1540
|
-
*/
|
|
1541
|
-
dispatched = /* @__PURE__ */ new Set();
|
|
1542
1543
|
/**
|
|
1543
1544
|
* Cache of the opencode root directory (from `GET /path`). Resolved lazily on
|
|
1544
1545
|
* first session creation so drain-created sessions are rooted at the project
|
|
@@ -1561,8 +1562,6 @@ var ChannelDriver = class {
|
|
|
1561
1562
|
this.sleep = config2.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1562
1563
|
this.pausedPollIntervalMs = config2.pausedPollIntervalMs ?? DEFAULT_PAUSED_POLL_INTERVAL_MS;
|
|
1563
1564
|
this.pausedMaxWaitMs = config2.pausedMaxWaitMs ?? DEFAULT_PAUSED_MAX_WAIT_MS;
|
|
1564
|
-
this.dispatchConfirmMs = config2.dispatchConfirmMs ?? DEFAULT_DISPATCH_CONFIRM_MS;
|
|
1565
|
-
this.now = config2.now ?? (() => Date.now());
|
|
1566
1565
|
}
|
|
1567
1566
|
/** The IPv4-loopback base URL for the local `opencode serve`. */
|
|
1568
1567
|
get opencodeBase() {
|
|
@@ -1572,16 +1571,16 @@ var ChannelDriver = class {
|
|
|
1572
1571
|
// Public API
|
|
1573
1572
|
// -------------------------------------------------------------------------
|
|
1574
1573
|
/**
|
|
1575
|
-
* Drain all pending channel conversations once: poll →
|
|
1574
|
+
* Drain all pending channel conversations once: poll → process → callback.
|
|
1576
1575
|
* Called on tunnel `connected` (WI-CHAN-4) and on each poll tick by `run.ts`.
|
|
1577
1576
|
* Re-entrant calls while a drain is in flight are skipped (return 0).
|
|
1578
1577
|
*
|
|
1579
|
-
* @returns the number of messages
|
|
1578
|
+
* @returns the number of messages processed.
|
|
1580
1579
|
*/
|
|
1581
1580
|
async drainPending() {
|
|
1582
1581
|
if (this.draining) return 0;
|
|
1583
1582
|
this.draining = true;
|
|
1584
|
-
let
|
|
1583
|
+
let processed = 0;
|
|
1585
1584
|
try {
|
|
1586
1585
|
const conversations = await this.getPendingConversations();
|
|
1587
1586
|
if (conversations.length > 0) {
|
|
@@ -1592,95 +1591,95 @@ var ChannelDriver = class {
|
|
|
1592
1591
|
});
|
|
1593
1592
|
}
|
|
1594
1593
|
for (const conv of conversations) {
|
|
1595
|
-
|
|
1594
|
+
processed += await this.processConversation(conv);
|
|
1596
1595
|
}
|
|
1597
1596
|
} finally {
|
|
1598
1597
|
this.draining = false;
|
|
1599
1598
|
}
|
|
1600
|
-
return
|
|
1601
|
-
}
|
|
1602
|
-
/**
|
|
1603
|
-
* True while any per-session watcher has a non-empty in-flight dispatched set
|
|
1604
|
-
* (Task 3.7). `run.ts` treats this as NON-idle so `--idle-timeout` cannot exit
|
|
1605
|
-
* the process while a dispatched message is still queued/running — which would
|
|
1606
|
-
* kill the turn and orphan its reply.
|
|
1607
|
-
*/
|
|
1608
|
-
hasInFlightWatchers() {
|
|
1609
|
-
for (const watcher of this.watchers.values()) {
|
|
1610
|
-
if (watcher.inFlight.size > 0) return true;
|
|
1611
|
-
}
|
|
1612
|
-
return false;
|
|
1599
|
+
return processed;
|
|
1613
1600
|
}
|
|
1614
1601
|
/**
|
|
1615
|
-
* Await all outstanding
|
|
1602
|
+
* Await all outstanding paused-session watchers (WI-2-CLI).
|
|
1616
1603
|
*
|
|
1617
|
-
* In production the
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1622
|
-
* reject, so this resolves.
|
|
1604
|
+
* In production the watchers are deliberately started-not-awaited so the drain
|
|
1605
|
+
* loop never blocks on them and process exit is not held up (the cron recovers
|
|
1606
|
+
* any abandoned ones). This helper exists primarily for deterministic tests
|
|
1607
|
+
* that need to observe the watcher's effect (the `done` PATCH or its giving up)
|
|
1608
|
+
* after a non-blocking `drainPending`. Watchers never reject, so this resolves.
|
|
1623
1609
|
*/
|
|
1624
1610
|
async flushPausedWatchers() {
|
|
1625
|
-
|
|
1626
|
-
const loops = [...this.watchers.values()].map((w) => w.loop).filter((l) => l != null);
|
|
1627
|
-
if (loops.length === 0) return;
|
|
1628
|
-
await Promise.all(loops);
|
|
1629
|
-
const stillLive = [...this.watchers.values()].some((w) => w.loop != null);
|
|
1630
|
-
if (!stillLive) return;
|
|
1631
|
-
}
|
|
1611
|
+
await Promise.all([...this.watchers.values()]);
|
|
1632
1612
|
}
|
|
1633
1613
|
// -------------------------------------------------------------------------
|
|
1634
|
-
// Conversation processing
|
|
1614
|
+
// Conversation processing
|
|
1635
1615
|
// -------------------------------------------------------------------------
|
|
1636
|
-
/**
|
|
1637
|
-
* Dispatch each pending message for a conversation to opencode's native queue
|
|
1638
|
-
* via `prompt_async` (Task 3.2) and register it with the conversation's
|
|
1639
|
-
* per-session watcher. Does NOT block on the turn and does NOT call
|
|
1640
|
-
* `markProcessing` here — that fires from the watcher on running-start.
|
|
1641
|
-
*
|
|
1642
|
-
* @returns the count of messages NEWLY dispatched (not already in-flight).
|
|
1643
|
-
*/
|
|
1644
1616
|
async processConversation(conv) {
|
|
1645
1617
|
const sessionId = await this.ensureSession(conv);
|
|
1646
1618
|
const messages = await this.getPendingMessages(conv.id);
|
|
1647
|
-
let
|
|
1619
|
+
let processed = 0;
|
|
1648
1620
|
for (const message of messages) {
|
|
1649
|
-
|
|
1621
|
+
const claimed = await this.markProcessing(conv.id, message.id, sessionId);
|
|
1622
|
+
if (!claimed) {
|
|
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
|
+
});
|
|
1650
1629
|
continue;
|
|
1651
1630
|
}
|
|
1652
|
-
const opencodeMessageId = opencodeMessageIdFor(message.id);
|
|
1653
|
-
const options = {
|
|
1654
|
-
agent: message.opencode_agent ?? void 0,
|
|
1655
|
-
model: message.opencode_model ?? void 0
|
|
1656
|
-
};
|
|
1657
1631
|
try {
|
|
1658
1632
|
this.log({
|
|
1659
1633
|
level: "info",
|
|
1660
|
-
message: `
|
|
1634
|
+
message: `Sending queued message ${message.id.slice(0, 8)} to OpenCode (session ${sessionId.slice(0, 8)})`,
|
|
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`,
|
|
1661
1667
|
conversation_id: conv.id,
|
|
1662
1668
|
message_id: message.id
|
|
1663
1669
|
});
|
|
1664
|
-
await sendPromptAsync(this.port, sessionId, message.content, options, opencodeMessageId);
|
|
1665
1670
|
} catch (err) {
|
|
1666
1671
|
if (err instanceof ChannelAuthError) throw err;
|
|
1667
|
-
this.dispatched.delete(message.id);
|
|
1668
1672
|
await this.markFailed(conv.id, message.id).catch(() => {
|
|
1669
1673
|
});
|
|
1670
1674
|
this.log({
|
|
1671
1675
|
level: "error",
|
|
1672
|
-
message: `Message ${message.id.slice(0, 8)}
|
|
1676
|
+
message: `Message ${message.id.slice(0, 8)} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1673
1677
|
conversation_id: conv.id,
|
|
1674
1678
|
message_id: message.id
|
|
1675
1679
|
});
|
|
1676
|
-
continue;
|
|
1677
1680
|
}
|
|
1678
|
-
this.dispatched.add(message.id);
|
|
1679
|
-
this.registerInFlight(conv, sessionId, message, opencodeMessageId);
|
|
1680
|
-
dispatched += 1;
|
|
1681
1681
|
}
|
|
1682
|
-
|
|
1683
|
-
return dispatched;
|
|
1682
|
+
return processed;
|
|
1684
1683
|
}
|
|
1685
1684
|
async ensureSession(conv) {
|
|
1686
1685
|
const cached = this.sessions.get(conv.id);
|
|
@@ -1712,349 +1711,111 @@ var ChannelDriver = class {
|
|
|
1712
1711
|
}
|
|
1713
1712
|
return this.opencodeDirectory;
|
|
1714
1713
|
}
|
|
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
|
+
}
|
|
1715
1741
|
// -------------------------------------------------------------------------
|
|
1716
|
-
//
|
|
1742
|
+
// Paused-session watcher (WI-2-CLI)
|
|
1717
1743
|
// -------------------------------------------------------------------------
|
|
1718
|
-
/** Register a freshly-dispatched message with its session's watcher state. */
|
|
1719
|
-
registerInFlight(conv, sessionId, message, opencodeMessageId) {
|
|
1720
|
-
let watcher = this.watchers.get(sessionId);
|
|
1721
|
-
if (!watcher) {
|
|
1722
|
-
watcher = {
|
|
1723
|
-
conv,
|
|
1724
|
-
inFlight: /* @__PURE__ */ new Map(),
|
|
1725
|
-
loop: null,
|
|
1726
|
-
reportedQuestions: /* @__PURE__ */ new Set(),
|
|
1727
|
-
reportedPermissions: /* @__PURE__ */ new Set()
|
|
1728
|
-
};
|
|
1729
|
-
this.watchers.set(sessionId, watcher);
|
|
1730
|
-
}
|
|
1731
|
-
const now = this.now();
|
|
1732
|
-
watcher.inFlight.set(message.id, {
|
|
1733
|
-
evidentMessageId: message.id,
|
|
1734
|
-
opencodeMessageId,
|
|
1735
|
-
message,
|
|
1736
|
-
dispatchedAt: now,
|
|
1737
|
-
deadline: now + this.pausedMaxWaitMs,
|
|
1738
|
-
started: false,
|
|
1739
|
-
done: false
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
1744
|
/**
|
|
1743
|
-
* Start (but do NOT await)
|
|
1744
|
-
*
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1745
|
+
* Start (but do NOT await) a watcher that resumes a paused turn to completion.
|
|
1746
|
+
*
|
|
1747
|
+
* Single-flight per message: if a watcher is already live for this message we
|
|
1748
|
+
* skip. The returned watcher promise is tracked in `this.watchers` and removed
|
|
1749
|
+
* when it settles; it never rejects (the body is fully guarded), so a failed
|
|
1750
|
+
* poll/markDone can never crash the run loop — the cron stays as the safety net.
|
|
1748
1751
|
*/
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
if (watcher.inFlight.size === 0) {
|
|
1754
|
-
this.watchers.delete(sessionId);
|
|
1755
|
-
return;
|
|
1756
|
-
}
|
|
1757
|
-
const loop = this.runWatcherLoop(sessionId, watcher).finally(() => {
|
|
1758
|
-
watcher.loop = null;
|
|
1759
|
-
if (watcher.inFlight.size === 0) {
|
|
1760
|
-
this.watchers.delete(sessionId);
|
|
1761
|
-
}
|
|
1752
|
+
startPausedWatcher(conv, message, sessionId) {
|
|
1753
|
+
if (this.watchers.has(message.id)) return;
|
|
1754
|
+
const watcher = this.watchPausedSession(conv, message, sessionId).finally(() => {
|
|
1755
|
+
this.watchers.delete(message.id);
|
|
1762
1756
|
});
|
|
1763
|
-
|
|
1757
|
+
this.watchers.set(message.id, watcher);
|
|
1764
1758
|
}
|
|
1765
1759
|
/**
|
|
1766
|
-
*
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
*
|
|
1772
|
-
*
|
|
1773
|
-
*
|
|
1774
|
-
*
|
|
1775
|
-
*
|
|
1776
|
-
*
|
|
1760
|
+
* Poll `GET /session/:id/message` until the SAME paused turn is message-level
|
|
1761
|
+
* complete — the last message is an ASSISTANT message whose
|
|
1762
|
+
* `info.time.completed` is set (the user answered the question/permission in
|
|
1763
|
+
* opencode web and opencode finished the turn) — then complete it via the
|
|
1764
|
+
* EXISTING `markDone` PATCH — NO re-send of the original message. The server
|
|
1765
|
+
* re-fetches the assistant messages on completion, so the watcher does not
|
|
1766
|
+
* pass any reply text.
|
|
1767
|
+
*
|
|
1768
|
+
* Fetch seam: the poll uses the injected `this.fetchImpl` (preserving the
|
|
1769
|
+
* tests' injection) and reuses only the pure `isTurnComplete` predicate from
|
|
1770
|
+
* `session.ts` — we do NOT call `getSessionMessages` (which uses the global
|
|
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.
|
|
1777
1778
|
*/
|
|
1778
|
-
async
|
|
1779
|
+
async watchPausedSession(conv, message, sessionId) {
|
|
1780
|
+
const deadline = Date.now() + this.pausedMaxWaitMs;
|
|
1779
1781
|
try {
|
|
1780
|
-
while (
|
|
1782
|
+
while (Date.now() < deadline) {
|
|
1781
1783
|
await this.sleep(this.pausedPollIntervalMs);
|
|
1782
|
-
let
|
|
1784
|
+
let completed = false;
|
|
1783
1785
|
try {
|
|
1784
1786
|
const res = await this.fetchImpl(`${this.opencodeBase}/session/${sessionId}/message`);
|
|
1785
1787
|
if (res.ok) {
|
|
1786
1788
|
const body = await res.json();
|
|
1787
|
-
messages = Array.isArray(body) ? body : null;
|
|
1789
|
+
const messages = Array.isArray(body) ? body : null;
|
|
1790
|
+
completed = isTurnComplete(messages);
|
|
1788
1791
|
}
|
|
1789
1792
|
} catch {
|
|
1790
1793
|
continue;
|
|
1791
1794
|
}
|
|
1792
|
-
|
|
1793
|
-
await this.serviceInFlightMessage(sessionId, watcher, inFlight, messages);
|
|
1794
|
-
}
|
|
1795
|
-
await this.pollInteractions(sessionId, watcher, messages);
|
|
1796
|
-
}
|
|
1797
|
-
} catch (err) {
|
|
1798
|
-
if (err instanceof ChannelAuthError) {
|
|
1799
|
-
this.log({
|
|
1800
|
-
level: "error",
|
|
1801
|
-
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}`,
|
|
1802
|
-
conversation_id: watcher.conv.id
|
|
1803
|
-
});
|
|
1804
|
-
for (const evidentMessageId of [...watcher.inFlight.keys()]) {
|
|
1805
|
-
this.removeInFlight(watcher, evidentMessageId);
|
|
1806
|
-
}
|
|
1807
|
-
return;
|
|
1808
|
-
}
|
|
1809
|
-
this.log({
|
|
1810
|
-
level: "error",
|
|
1811
|
-
message: `Session watcher failed for session ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1812
|
-
conversation_id: watcher.conv.id
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
}
|
|
1816
|
-
/**
|
|
1817
|
-
* Drive ONE in-flight message's lifecycle from the tick's message snapshot.
|
|
1818
|
-
* Fires markProcessing on queued→running and markDone on done (each once),
|
|
1819
|
-
* applies the idle-path re-dispatch guard, and removes the message from the
|
|
1820
|
-
* in-flight set on completion or timeout.
|
|
1821
|
-
*/
|
|
1822
|
-
async serviceInFlightMessage(sessionId, watcher, inFlight, messages) {
|
|
1823
|
-
const conv = watcher.conv;
|
|
1824
|
-
const state = messageRunState(messages, inFlight.opencodeMessageId);
|
|
1825
|
-
if ((state === "running" || state === "done") && !inFlight.started) {
|
|
1826
|
-
let claimed;
|
|
1827
|
-
try {
|
|
1828
|
-
claimed = await this.markProcessing(conv.id, inFlight.evidentMessageId, sessionId);
|
|
1829
|
-
} catch (err) {
|
|
1830
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
1831
|
-
this.log({
|
|
1832
|
-
level: "error",
|
|
1833
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} processing (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1834
|
-
conversation_id: conv.id,
|
|
1835
|
-
message_id: inFlight.evidentMessageId
|
|
1836
|
-
});
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
inFlight.started = true;
|
|
1840
|
-
if (!claimed) {
|
|
1841
|
-
this.log({
|
|
1842
|
-
level: "info",
|
|
1843
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} already marked processing \u2014 continuing`,
|
|
1844
|
-
conversation_id: conv.id,
|
|
1845
|
-
message_id: inFlight.evidentMessageId
|
|
1846
|
-
});
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
if (state === "done") {
|
|
1850
|
-
if (!inFlight.done) {
|
|
1795
|
+
if (!completed) continue;
|
|
1851
1796
|
this.log({
|
|
1852
1797
|
level: "info",
|
|
1853
|
-
message: `
|
|
1798
|
+
message: `Paused session ${sessionId.slice(0, 8)} completed \u2014 marking message ${message.id.slice(0, 8)} done`,
|
|
1854
1799
|
conversation_id: conv.id,
|
|
1855
|
-
message_id:
|
|
1800
|
+
message_id: message.id
|
|
1856
1801
|
});
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
} catch (err) {
|
|
1860
|
-
if (err instanceof ChannelAuthError) throw err;
|
|
1861
|
-
if (err instanceof ChannelTerminalError) {
|
|
1862
|
-
this.log({
|
|
1863
|
-
level: "error",
|
|
1864
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (terminal HTTP ${err.status}) \u2014 leaving for the cron safety net: ${err.message}`,
|
|
1865
|
-
conversation_id: conv.id,
|
|
1866
|
-
message_id: inFlight.evidentMessageId
|
|
1867
|
-
});
|
|
1868
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1869
|
-
return;
|
|
1870
|
-
}
|
|
1871
|
-
if (this.now() >= inFlight.deadline) {
|
|
1872
|
-
this.log({
|
|
1873
|
-
level: "error",
|
|
1874
|
-
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)}`,
|
|
1875
|
-
conversation_id: conv.id,
|
|
1876
|
-
message_id: inFlight.evidentMessageId
|
|
1877
|
-
});
|
|
1878
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1879
|
-
return;
|
|
1880
|
-
}
|
|
1881
|
-
this.log({
|
|
1882
|
-
level: "error",
|
|
1883
|
-
message: `Failed to mark message ${inFlight.evidentMessageId.slice(0, 8)} done (will retry next tick): ${err instanceof Error ? err.message : String(err)}`,
|
|
1884
|
-
conversation_id: conv.id,
|
|
1885
|
-
message_id: inFlight.evidentMessageId
|
|
1886
|
-
});
|
|
1887
|
-
return;
|
|
1888
|
-
}
|
|
1889
|
-
inFlight.done = true;
|
|
1890
|
-
}
|
|
1891
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1892
|
-
return;
|
|
1893
|
-
}
|
|
1894
|
-
if (state === "unknown") {
|
|
1895
|
-
if (this.now() - inFlight.dispatchedAt >= this.dispatchConfirmMs) {
|
|
1896
|
-
await this.redispatchInFlight(sessionId, inFlight);
|
|
1802
|
+
await this.markDone(conv.id, message.id, sessionId);
|
|
1803
|
+
return;
|
|
1897
1804
|
}
|
|
1898
|
-
}
|
|
1899
|
-
if (this.now() >= inFlight.deadline) {
|
|
1900
1805
|
this.log({
|
|
1901
1806
|
level: "info",
|
|
1902
|
-
message: `
|
|
1807
|
+
message: `Paused session ${sessionId.slice(0, 8)} did not complete within the watch window \u2014 leaving message ${message.id.slice(0, 8)} for the cron safety net`,
|
|
1903
1808
|
conversation_id: conv.id,
|
|
1904
|
-
message_id:
|
|
1809
|
+
message_id: message.id
|
|
1905
1810
|
});
|
|
1906
|
-
this.removeInFlight(watcher, inFlight.evidentMessageId);
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
/**
|
|
1910
|
-
* Re-dispatch a message whose user row never appeared (idle-path guard). Safe:
|
|
1911
|
-
* opencode treats a duplicate caller-supplied `messageID` as idempotent (PoC
|
|
1912
|
-
* fact 9) — one user message + one reply even if the original DID land. Resets
|
|
1913
|
-
* the dispatch timestamp so the guard doesn't immediately fire again.
|
|
1914
|
-
*/
|
|
1915
|
-
async redispatchInFlight(sessionId, inFlight) {
|
|
1916
|
-
const options = {
|
|
1917
|
-
agent: inFlight.message.opencode_agent ?? void 0,
|
|
1918
|
-
model: inFlight.message.opencode_model ?? void 0
|
|
1919
|
-
};
|
|
1920
|
-
this.log({
|
|
1921
|
-
level: "info",
|
|
1922
|
-
message: `Message ${inFlight.evidentMessageId.slice(0, 8)} not observed after dispatch \u2014 re-dispatching (idle-path guard)`,
|
|
1923
|
-
message_id: inFlight.evidentMessageId
|
|
1924
|
-
});
|
|
1925
|
-
try {
|
|
1926
|
-
await sendPromptAsync(
|
|
1927
|
-
this.port,
|
|
1928
|
-
sessionId,
|
|
1929
|
-
inFlight.message.content,
|
|
1930
|
-
options,
|
|
1931
|
-
inFlight.opencodeMessageId
|
|
1932
|
-
);
|
|
1933
1811
|
} catch (err) {
|
|
1934
1812
|
this.log({
|
|
1935
1813
|
level: "error",
|
|
1936
|
-
message: `
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
}
|
|
1940
|
-
inFlight.dispatchedAt = this.now();
|
|
1941
|
-
}
|
|
1942
|
-
/**
|
|
1943
|
-
* Remove a message from the in-flight set AND the authoritative dispatched
|
|
1944
|
-
* set. Once the in-flight set empties, the watcher loop's `while` guard exits
|
|
1945
|
-
* and its `.finally` removes the session entry from `this.watchers`.
|
|
1946
|
-
*/
|
|
1947
|
-
removeInFlight(watcher, evidentMessageId) {
|
|
1948
|
-
watcher.inFlight.delete(evidentMessageId);
|
|
1949
|
-
this.dispatched.delete(evidentMessageId);
|
|
1950
|
-
}
|
|
1951
|
-
/**
|
|
1952
|
-
* Poll `/question` + `/permission` (scoped to the session) and surface NEW ones
|
|
1953
|
-
* via `reportInteraction` (Task 3.5), carrying the PAUSED message's own
|
|
1954
|
-
* `source_message_id` so the server @mentions the correct person under
|
|
1955
|
-
* concurrency. Dedups by interaction id across ticks (reused per-session sets).
|
|
1956
|
-
*
|
|
1957
|
-
* The interaction is attributed to the in-flight message it paused on. opencode
|
|
1958
|
-
* stamps a `messageID` on a permission (and `tool.messageID` on a question) =
|
|
1959
|
-
* the assistant message id, whose `parentID` is the user message id — but the
|
|
1960
|
-
* simplest robust attribution here is: the single in-flight message that is
|
|
1961
|
-
* RUNNING (not done) is the one that paused. With one running message that is
|
|
1962
|
-
* unambiguous; with several we prefer an explicit messageID match, else the
|
|
1963
|
-
* oldest running message.
|
|
1964
|
-
*/
|
|
1965
|
-
async pollInteractions(sessionId, watcher, messages) {
|
|
1966
|
-
let questions = [];
|
|
1967
|
-
try {
|
|
1968
|
-
const res = await this.fetchImpl(`${this.opencodeBase}/question`);
|
|
1969
|
-
if (res.ok) {
|
|
1970
|
-
const body = await res.json();
|
|
1971
|
-
questions = Array.isArray(body) ? body : [];
|
|
1972
|
-
}
|
|
1973
|
-
} catch {
|
|
1974
|
-
}
|
|
1975
|
-
for (const q of questions) {
|
|
1976
|
-
if (q.sessionID !== sessionId) continue;
|
|
1977
|
-
if (watcher.reportedQuestions.has(q.id)) continue;
|
|
1978
|
-
const paused = this.attributeInteraction(watcher, q.tool?.messageID, messages);
|
|
1979
|
-
const reported = await this.reportInteraction(
|
|
1980
|
-
watcher.conv.id,
|
|
1981
|
-
"question",
|
|
1982
|
-
q,
|
|
1983
|
-
paused?.message.source_message_id ?? void 0
|
|
1984
|
-
);
|
|
1985
|
-
if (reported) watcher.reportedQuestions.add(q.id);
|
|
1986
|
-
}
|
|
1987
|
-
let permissions = [];
|
|
1988
|
-
try {
|
|
1989
|
-
const res = await this.fetchImpl(`${this.opencodeBase}/permission`);
|
|
1990
|
-
if (res.ok) {
|
|
1991
|
-
const body = await res.json();
|
|
1992
|
-
permissions = Array.isArray(body) ? body : [];
|
|
1993
|
-
}
|
|
1994
|
-
} catch {
|
|
1995
|
-
}
|
|
1996
|
-
for (const p of permissions) {
|
|
1997
|
-
if (p.sessionID !== sessionId) continue;
|
|
1998
|
-
if (watcher.reportedPermissions.has(p.id)) continue;
|
|
1999
|
-
const paused = this.attributeInteraction(watcher, p.messageID, messages);
|
|
2000
|
-
const reported = await this.reportInteraction(
|
|
2001
|
-
watcher.conv.id,
|
|
2002
|
-
"permission",
|
|
2003
|
-
p,
|
|
2004
|
-
paused?.message.source_message_id ?? void 0
|
|
2005
|
-
);
|
|
2006
|
-
if (reported) watcher.reportedPermissions.add(p.id);
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
/**
|
|
2010
|
-
* Attribute a surfaced interaction to the in-flight message it paused on (M-1).
|
|
2011
|
-
*
|
|
2012
|
-
* The interaction carries `interactionMessageId` — the ASSISTANT message id
|
|
2013
|
-
* that raised it (a question's `tool.messageID` / a permission's `messageID`).
|
|
2014
|
-
* That assistant message is the reply to ONE of our minted user messages
|
|
2015
|
-
* (correlated by `parentID`, GATE-B). So when we have the tick's message
|
|
2016
|
-
* snapshot, we resolve each running in-flight message's correlated assistant
|
|
2017
|
-
* reply (`findAssistantReplyAfter`) and match its id against
|
|
2018
|
-
* `interactionMessageId` — giving an EXACT attribution even with several
|
|
2019
|
-
* messages in flight concurrently in one session.
|
|
2020
|
-
*
|
|
2021
|
-
* We fall back to the oldest running message ONLY when no exact match is
|
|
2022
|
-
* possible (the id is absent, the snapshot is missing, or the reply has not yet
|
|
2023
|
-
* been correlated). With a single running message either path is exact. Never
|
|
2024
|
-
* throws.
|
|
2025
|
-
*
|
|
2026
|
-
* Attribution must NOT depend on our own `started` PATCH flag: opencode can
|
|
2027
|
-
* START a turn AND raise a question/permission BEFORE our next tick fires
|
|
2028
|
-
* `markProcessing` (which sets `started`). Relying on `started` would leave the
|
|
2029
|
-
* running set empty in that window and let the server fall back to "newest
|
|
2030
|
-
* processing/pending" — possibly @mentioning a FOLLOW-UP author rather than the
|
|
2031
|
-
* person whose active turn actually paused. So we derive "running" from the
|
|
2032
|
-
* tick's `messages` snapshot via `messageRunState` instead.
|
|
2033
|
-
*/
|
|
2034
|
-
attributeInteraction(watcher, interactionMessageId, messages) {
|
|
2035
|
-
const inFlight = [...watcher.inFlight.values()].filter((m) => !m.done);
|
|
2036
|
-
if (inFlight.length === 0) return void 0;
|
|
2037
|
-
if (interactionMessageId && messages) {
|
|
2038
|
-
const exact = inFlight.find((m) => {
|
|
2039
|
-
const reply = findAssistantReplyAfter(messages, m.opencodeMessageId);
|
|
2040
|
-
return reply != null && messageIdOf(reply) === interactionMessageId;
|
|
1814
|
+
message: `Paused-session watcher failed for message ${message.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1815
|
+
conversation_id: conv.id,
|
|
1816
|
+
message_id: message.id
|
|
2041
1817
|
});
|
|
2042
|
-
if (exact) return exact;
|
|
2043
|
-
}
|
|
2044
|
-
const byOldest = (a, b) => a.dispatchedAt - b.dispatchedAt;
|
|
2045
|
-
if (messages) {
|
|
2046
|
-
const runningPerSnapshot = inFlight.filter(
|
|
2047
|
-
(m) => messageRunState(messages, m.opencodeMessageId) === "running"
|
|
2048
|
-
);
|
|
2049
|
-
if (runningPerSnapshot.length > 0) {
|
|
2050
|
-
return runningPerSnapshot.sort(byOldest)[0];
|
|
2051
|
-
}
|
|
2052
1818
|
}
|
|
2053
|
-
const startedRunning = inFlight.filter((m) => m.started);
|
|
2054
|
-
if (startedRunning.length > 0) {
|
|
2055
|
-
return startedRunning.sort(byOldest)[0];
|
|
2056
|
-
}
|
|
2057
|
-
return inFlight.sort(byOldest)[0];
|
|
2058
1819
|
}
|
|
2059
1820
|
// -------------------------------------------------------------------------
|
|
2060
1821
|
// Evident API calls (combinedAuth thread routes)
|
|
@@ -2088,86 +1849,39 @@ var ChannelDriver = class {
|
|
|
2088
1849
|
}
|
|
2089
1850
|
return await res.json();
|
|
2090
1851
|
}
|
|
2091
|
-
/**
|
|
2092
|
-
* EXISTING combinedAuth route — now fired by the watcher on queued→running
|
|
2093
|
-
* (Task 3.3), NOT at dispatch/claim time. `{status:'processing',
|
|
2094
|
-
* opencode_session_id}` → `notifyMessageStarted` (hourglass→runner swap +
|
|
2095
|
-
* deep-linked "View in Evident" notice).
|
|
2096
|
-
*
|
|
2097
|
-
* Return/throw contract (consumed by the watcher's swap-to-running guard):
|
|
2098
|
-
* - returns `true` → the server transitioned the row to processing;
|
|
2099
|
-
* - returns `false` → the server gave a DEFINITIVE "already-processing"
|
|
2100
|
-
* answer (a non-retryable, non-auth status — e.g. a
|
|
2101
|
-
* conflict because a duplicate already transitioned it),
|
|
2102
|
-
* so the caller treats it as already-started and does NOT
|
|
2103
|
-
* retry;
|
|
2104
|
-
* - throws `ChannelAuthError` on 401/403 (terminal auth failure);
|
|
2105
|
-
* - throws on a TRANSIENT failure (retryable 5xx/429 status, or a
|
|
2106
|
-
* network-level error from `fetch`) — i.e. NO definitive server response —
|
|
2107
|
-
* so the caller leaves the message un-started and retries the swap on the
|
|
2108
|
-
* next tick.
|
|
2109
|
-
* A single attempt (no internal retry): the watcher's per-tick loop is the
|
|
2110
|
-
* retry vehicle for the swap-to-running.
|
|
2111
|
-
*/
|
|
2112
1852
|
async markProcessing(conversationId, messageId, sessionId) {
|
|
2113
1853
|
const res = await this.fetchImpl(
|
|
2114
1854
|
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
2115
1855
|
{
|
|
2116
1856
|
method: "PATCH",
|
|
2117
1857
|
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).
|
|
2118
1861
|
body: JSON.stringify({ status: "processing", opencode_session_id: sessionId })
|
|
2119
1862
|
}
|
|
2120
1863
|
);
|
|
2121
1864
|
this.assertAuth(res, "marking message as processing");
|
|
2122
|
-
|
|
2123
|
-
if (isRetryableStatus(res.status)) {
|
|
2124
|
-
throw new Error(`marking message as processing: HTTP ${res.status}`);
|
|
2125
|
-
}
|
|
2126
|
-
return false;
|
|
1865
|
+
return res.ok;
|
|
2127
1866
|
}
|
|
2128
1867
|
/**
|
|
2129
|
-
* EXISTING combinedAuth completion route — idempotent (WI-CHAN-2).
|
|
2130
|
-
* .../messages/:id {status:'done', opencode_session_id}`. The server's
|
|
1868
|
+
* EXISTING combinedAuth completion route — idempotent + retried (WI-CHAN-2).
|
|
1869
|
+
* `PATCH .../messages/:id {status:'done', opencode_session_id}`. The server's
|
|
2131
1870
|
* `queued_conversation_messages.status`/`processed_at` gate makes a re-call
|
|
2132
|
-
* for an already-`done` message a no-op (no double Slack post).
|
|
2133
|
-
* watcher on per-message completion (Task 3.4) — no `confirmCompletion`
|
|
2134
|
-
* round-trip (we already observed completion via the message list).
|
|
2135
|
-
*
|
|
2136
|
-
* SINGLE ATTEMPT (no in-call `callWithRetry` backoff). The per-session watcher
|
|
2137
|
-
* services its in-flight messages SEQUENTIALLY within a tick
|
|
2138
|
-
* (`runWatcherLoop` → `serviceInFlightMessage`), so a long multi-attempt
|
|
2139
|
-
* backoff here would BLOCK sibling messages in the SAME session/tick: while
|
|
2140
|
-
* message A's done PATCH burned its internal retries, message B could not be
|
|
2141
|
-
* swapped to running even though opencode had already started it. Instead this
|
|
2142
|
-
* does ONE PATCH and surfaces the SAME outcome contract the watcher's markDone
|
|
2143
|
-
* handler already relies on, leaning on the per-tick retry across ticks
|
|
2144
|
-
* (bounded by `inFlight.deadline`) rather than an in-call retry:
|
|
2145
|
-
* - resolves (`void`) → the server transitioned the row to done
|
|
2146
|
-
* (or idempotently confirmed already-done);
|
|
2147
|
-
* - throws `ChannelAuthError` → 401/403 (terminal auth failure → loop
|
|
2148
|
-
* cleanup, Finding 1);
|
|
2149
|
-
* - throws `ChannelTerminalError`→ non-retryable, non-auth 4xx (will never
|
|
2150
|
-
* succeed → straight to the cron, Finding 4);
|
|
2151
|
-
* - throws a plain `Error` → TRANSIENT 5xx/429 or a network-level error
|
|
2152
|
-
* (no definitive server response → the
|
|
2153
|
-
* watcher retries next tick within the
|
|
2154
|
-
* deadline, Finding 4).
|
|
1871
|
+
* for an already-`done` message a no-op (no double Slack post).
|
|
2155
1872
|
*/
|
|
2156
1873
|
async markDone(conversationId, messageId, sessionId) {
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
1874
|
+
await this.callWithRetry(
|
|
1875
|
+
"marking message as done",
|
|
1876
|
+
() => this.fetchImpl(
|
|
1877
|
+
`${this.apiUrl}/agents/${this.agentId}/threads/${conversationId}/messages/${messageId}`,
|
|
1878
|
+
{
|
|
1879
|
+
method: "PATCH",
|
|
1880
|
+
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
1881
|
+
body: JSON.stringify({ status: "done", opencode_session_id: sessionId })
|
|
1882
|
+
}
|
|
1883
|
+
)
|
|
2164
1884
|
);
|
|
2165
|
-
this.assertAuth(res, "marking message as done");
|
|
2166
|
-
if (res.ok) return;
|
|
2167
|
-
if (isRetryableStatus(res.status)) {
|
|
2168
|
-
throw new Error(`marking message as done: HTTP ${res.status}`);
|
|
2169
|
-
}
|
|
2170
|
-
throw new ChannelTerminalError(`marking message as done: HTTP ${res.status}`, res.status);
|
|
2171
1885
|
}
|
|
2172
1886
|
async markFailed(conversationId, messageId) {
|
|
2173
1887
|
await this.callWithRetry(
|
|
@@ -2195,17 +1909,10 @@ var ChannelDriver = class {
|
|
|
2195
1909
|
}
|
|
2196
1910
|
/**
|
|
2197
1911
|
* EXISTING combinedAuth interaction route (WI-CHAN-3) — idempotent + retried.
|
|
2198
|
-
* `POST .../interactive-event {type, data
|
|
2199
|
-
*
|
|
2200
|
-
* conversation, @mentioning the user who triggered THIS message's turn.
|
|
2201
|
-
*
|
|
2202
|
-
* WI-3 / WI-4 contract: `source_message_id` is the PAUSED message's own Slack
|
|
2203
|
-
* ts (`message.source_message_id`). The server resolves the @mention from that
|
|
2204
|
-
* message's user FIRST (falling back to the old "newest processing" precedence
|
|
2205
|
-
* only when absent), so the correct person is mentioned under concurrency. It
|
|
2206
|
-
* is OPTIONAL for back-compat with older clients / legacy rows.
|
|
1912
|
+
* `POST .../interactive-event {type, data}`. The server persists the
|
|
1913
|
+
* interaction and posts a link to the proxied opencode-web conversation.
|
|
2207
1914
|
*/
|
|
2208
|
-
async reportInteraction(conversationId, type, data
|
|
1915
|
+
async reportInteraction(conversationId, type, data) {
|
|
2209
1916
|
try {
|
|
2210
1917
|
await this.callWithRetry(
|
|
2211
1918
|
"reporting interactive event",
|
|
@@ -2214,9 +1921,7 @@ var ChannelDriver = class {
|
|
|
2214
1921
|
{
|
|
2215
1922
|
method: "POST",
|
|
2216
1923
|
headers: { Authorization: this.getAuthHeader(), "Content-Type": "application/json" },
|
|
2217
|
-
body: JSON.stringify(
|
|
2218
|
-
sourceMessageId ? { type, data, source_message_id: sourceMessageId } : { type, data }
|
|
2219
|
-
)
|
|
1924
|
+
body: JSON.stringify({ type, data })
|
|
2220
1925
|
}
|
|
2221
1926
|
)
|
|
2222
1927
|
);
|
|
@@ -2225,7 +1930,6 @@ var ChannelDriver = class {
|
|
|
2225
1930
|
message: `${type} surfaced to channel (id: ${data.id.slice(0, 8)})`,
|
|
2226
1931
|
conversation_id: conversationId
|
|
2227
1932
|
});
|
|
2228
|
-
return true;
|
|
2229
1933
|
} catch (err) {
|
|
2230
1934
|
if (err instanceof ChannelAuthError) throw err;
|
|
2231
1935
|
this.log({
|
|
@@ -2233,7 +1937,6 @@ var ChannelDriver = class {
|
|
|
2233
1937
|
message: `Failed to surface ${type}: ${err instanceof Error ? err.message : String(err)}`,
|
|
2234
1938
|
conversation_id: conversationId
|
|
2235
1939
|
});
|
|
2236
|
-
return false;
|
|
2237
1940
|
}
|
|
2238
1941
|
}
|
|
2239
1942
|
// -------------------------------------------------------------------------
|
|
@@ -2272,9 +1975,8 @@ var ChannelDriver = class {
|
|
|
2272
1975
|
await this.sleep(backoffDelay(attempt, this.retry));
|
|
2273
1976
|
continue;
|
|
2274
1977
|
}
|
|
2275
|
-
break;
|
|
2276
1978
|
}
|
|
2277
|
-
throw new
|
|
1979
|
+
throw new Error(`${context}: HTTP ${res.status}`);
|
|
2278
1980
|
}
|
|
2279
1981
|
throw lastError instanceof Error ? lastError : new Error(`${context}: exhausted retries`);
|
|
2280
1982
|
}
|
|
@@ -2619,9 +2321,9 @@ async function driveChannels(state, driver) {
|
|
|
2619
2321
|
try {
|
|
2620
2322
|
const processed = await driver.drainPending();
|
|
2621
2323
|
state.messageCount += processed;
|
|
2622
|
-
if (processed > 0
|
|
2324
|
+
if (processed > 0) {
|
|
2623
2325
|
idlePolls = 0;
|
|
2624
|
-
if (
|
|
2326
|
+
if (state.interactive) displayStatus(state);
|
|
2625
2327
|
} else if (state.idleTimeout !== null) {
|
|
2626
2328
|
idlePolls++;
|
|
2627
2329
|
if (idlePolls === 1) {
|
|
@@ -2812,13 +2514,6 @@ async function run(options) {
|
|
|
2812
2514
|
state.opencodeConnected = oc.process !== null || oc.version !== null;
|
|
2813
2515
|
const version = state.opencodeVersion ? ` (v${state.opencodeVersion})` : "";
|
|
2814
2516
|
ocSpinner?.succeed(`OpenCode running on port ${state.port}${version}`);
|
|
2815
|
-
const versionWarning = buildOpenCodeVersionWarning(state.opencodeVersion);
|
|
2816
|
-
if (versionWarning) {
|
|
2817
|
-
log(state, versionWarning, false);
|
|
2818
|
-
if (state.interactive && !state.json) {
|
|
2819
|
-
logActivity(state, { type: "info", message: versionWarning });
|
|
2820
|
-
}
|
|
2821
|
-
}
|
|
2822
2517
|
} catch (error2) {
|
|
2823
2518
|
ocSpinner?.fail(error2.message);
|
|
2824
2519
|
throw error2;
|