@cello-protocol/daemon 0.0.29 → 0.0.31
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/daemon.d.ts.map +1 -1
- package/dist/daemon.js +277 -32
- package/dist/daemon.js.map +1 -1
- package/dist/session-node-manager.d.ts +21 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +84 -1
- package/dist/session-node-manager.js.map +1 -1
- package/dist/signaling-connect.js +7 -2
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/dist/daemon.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EASrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAsB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAO/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AA0InF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EASrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAM/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAsB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAO/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AA0InF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CA0uJ7E"}
|
package/dist/daemon.js
CHANGED
|
@@ -859,11 +859,16 @@ export async function startDaemon(config) {
|
|
|
859
859
|
}
|
|
860
860
|
}
|
|
861
861
|
/**
|
|
862
|
-
* Open a transient VISITING signaling connection into a specific directory node (the target's home
|
|
863
|
-
* and wire
|
|
864
|
-
* broker asks the initiator to co-sign the assignment
|
|
865
|
-
*
|
|
866
|
-
*
|
|
862
|
+
* Open a transient VISITING signaling connection into a specific directory node (the target's home /
|
|
863
|
+
* broker) and wire what a cross-node initiator needs there:
|
|
864
|
+
* - the delegated-signer ceremony handler (the broker asks the initiator to co-sign the assignment
|
|
865
|
+
* over THIS connection); and
|
|
866
|
+
* - Fix #1 (cross-node seal-liveness): the seal ceremony handler + session_sealed/unilateral
|
|
867
|
+
* listeners, because for a cross-node CLOSE the broker ALSO pushes seal_verified then session_sealed
|
|
868
|
+
* over the connection the initiator holds to it. The seal FROST still runs over the agent's OWN
|
|
869
|
+
* roster (getNode: () => nodeRef) — only the control frames traverse this stream.
|
|
870
|
+
* Transient and initiator-only. The caller MUST stop() it after the assignment (setup path) or after
|
|
871
|
+
* the seal reaches a terminal outcome (close path).
|
|
867
872
|
*/
|
|
868
873
|
function openVisitingConnection(agentName, agentKeyProvider, agentPubkeyHex, endpoint, correlationId, nodeId) {
|
|
869
874
|
let nodeRef = null;
|
|
@@ -888,6 +893,24 @@ export async function startDaemon(config) {
|
|
|
888
893
|
signaling: mgr,
|
|
889
894
|
logger,
|
|
890
895
|
});
|
|
896
|
+
// Fix #1 (cross-node seal-liveness): a visiting connection must ALSO be able to complete the SEAL.
|
|
897
|
+
// The broker (the node this visiting connection targets) pushes seal_verified then session_sealed to
|
|
898
|
+
// whichever stream the initiator holds to it — for a cross-node close that is THIS visiting stream.
|
|
899
|
+
// Run the seal FROST ceremony over the agent's own node (getNode: () => nodeRef, exactly as the
|
|
900
|
+
// session ceremony above) and reply/resolve on this stream. Harmless on a setup-only visiting
|
|
901
|
+
// connection: these frames never arrive before it is released after handoff.
|
|
902
|
+
wireSealCeremonyHandler({
|
|
903
|
+
agentName,
|
|
904
|
+
persistence: getPersistence(agentName),
|
|
905
|
+
agentPubkeyHex,
|
|
906
|
+
getNode: () => nodeRef,
|
|
907
|
+
getDirectoryEndpoint: getFailoverEndpoint,
|
|
908
|
+
getConsortiumEndpoints: resolveConsortiumRoster,
|
|
909
|
+
signaling: mgr,
|
|
910
|
+
logger,
|
|
911
|
+
});
|
|
912
|
+
registerSessionSealedListener(mgr, agentName, agentPubkeyHex);
|
|
913
|
+
registerUnilateralConfirmedListener(mgr, agentName, agentPubkeyHex);
|
|
891
914
|
logger.info("signaling.visiting.connected", { agentName, node: nodeId, correlationId });
|
|
892
915
|
return {
|
|
893
916
|
mgr,
|
|
@@ -897,6 +920,16 @@ export async function startDaemon(config) {
|
|
|
897
920
|
},
|
|
898
921
|
};
|
|
899
922
|
}
|
|
923
|
+
// Fix #1 (cross-node seal-liveness): the broker node (the counterparty's home) for each cross-node
|
|
924
|
+
// session we initiated, keyed `${agentName}:${sessionIdHex}`. Set when runCrossNodeSetup establishes
|
|
925
|
+
// the session; read by cello_close_session so it can re-open a visiting connection to the broker and
|
|
926
|
+
// complete the seal there. The broker pushes seal_verified/session_sealed, but the initiator RELEASES
|
|
927
|
+
// its visiting connection after setup — so without reconnecting, the close times out
|
|
928
|
+
// (seal_unilateral_timeout) and the seal only completes whenever the daemon next happens to reconnect.
|
|
929
|
+
// Presence of an entry means the session is cross-node (same-node sessions never go through
|
|
930
|
+
// runCrossNodeSetup). In-memory only: a close in the SAME process (the common case) is covered; a close
|
|
931
|
+
// after a daemon restart falls back to the pre-fix behavior (deferred hardening: persist on the row).
|
|
932
|
+
const crossNodeBrokerBySession = new Map();
|
|
900
933
|
/**
|
|
901
934
|
* Resolve `owningNodeId` through the signed manifest and run the session_request over a transient
|
|
902
935
|
* visiting connection there. A node id that doesn't resolve in the roster is a hard error
|
|
@@ -926,6 +959,9 @@ export async function startDaemon(config) {
|
|
|
926
959
|
const result = await runSessionRequestOverSignaling(visiting.mgr, targetHex, sr, correlationId, agentName);
|
|
927
960
|
if (result.ok) {
|
|
928
961
|
releaseReason = "handoff-complete";
|
|
962
|
+
// Fix #1: remember the broker for this session so cello_close_session can reconnect to complete the seal.
|
|
963
|
+
const brokerSessionIdHex = Buffer.from(result.assignment.session_id).toString("hex");
|
|
964
|
+
crossNodeBrokerBySession.set(`${agentName}:${brokerSessionIdHex}`, owningNodeId);
|
|
929
965
|
logger.info("session.crossnode.established", { agentName, brokerNode: owningNodeId, correlationId });
|
|
930
966
|
}
|
|
931
967
|
else {
|
|
@@ -1218,19 +1254,16 @@ export async function startDaemon(config) {
|
|
|
1218
1254
|
return agents
|
|
1219
1255
|
.filter((a) => a.state !== "load_failed")
|
|
1220
1256
|
.map((a) => {
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
}
|
|
1228
|
-
else {
|
|
1229
|
-
state = "registered";
|
|
1230
|
-
}
|
|
1257
|
+
// M8C-AUTOSTART-1 (F5): `state` reports readiness only (online vs registered); selection
|
|
1258
|
+
// is a SEPARATE `selected` flag. Previously `state = "current"` overloaded the current
|
|
1259
|
+
// agent, making it read as a different readiness level than a second healthy online agent.
|
|
1260
|
+
const online = onlineAgents.has(a.name);
|
|
1261
|
+
const state = online ? "online" : "registered";
|
|
1262
|
+
const selected = online && a.name === currentAgent;
|
|
1231
1263
|
return {
|
|
1232
1264
|
name: a.name,
|
|
1233
1265
|
state,
|
|
1266
|
+
selected,
|
|
1234
1267
|
pubkey: a.pubkey,
|
|
1235
1268
|
// M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
|
|
1236
1269
|
// (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
|
|
@@ -1312,11 +1345,13 @@ export async function startDaemon(config) {
|
|
|
1312
1345
|
return { connectionId };
|
|
1313
1346
|
});
|
|
1314
1347
|
// ─── MCP-001: cello_start_agent handler ───
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1348
|
+
// M8C-AUTOSTART-1 (A2): the shared start path. Extracted from cello_start_agent so cello_use_agent
|
|
1349
|
+
// can AUTO-START an offline agent through the exact same code (idempotent, same signaling +
|
|
1350
|
+
// standing-receiver setup, same agent_state_changed event) — never a divergent shim-side retry.
|
|
1351
|
+
// Permissive by design (D12): an agent that exists goes online regardless of directory
|
|
1352
|
+
// registration state (online-without-registration is an established contract). Returns a
|
|
1353
|
+
// structured failure so callers can surface agent_start_failed with a real reason + guidance.
|
|
1354
|
+
function startAgentInternal(name) {
|
|
1320
1355
|
const agent = agents.find((a) => a.name === name);
|
|
1321
1356
|
if (!agent || agent.state === "load_failed") {
|
|
1322
1357
|
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_list_agents.` };
|
|
@@ -1361,6 +1396,29 @@ export async function startDaemon(config) {
|
|
|
1361
1396
|
// MCP-002: Broadcast agent_state_changed to ALL connections
|
|
1362
1397
|
notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
|
|
1363
1398
|
return { ok: true };
|
|
1399
|
+
}
|
|
1400
|
+
// M8C-AUTOSTART-1 (F18): resolve which agent a name-defaulting tool should act on for this
|
|
1401
|
+
// connection: an explicit name wins; else the connection's current agent; else — when EXACTLY one
|
|
1402
|
+
// agent is online daemon-wide — that sole agent (removes the "why did it forget my agent" moment
|
|
1403
|
+
// after a /mcp reconnect). Two-or-more online with none selected stays ambiguous → null (the
|
|
1404
|
+
// caller returns no_current_agent), because guessing between peers would misroute.
|
|
1405
|
+
function resolveCurrentAgent(connState, explicitName) {
|
|
1406
|
+
if (explicitName)
|
|
1407
|
+
return explicitName;
|
|
1408
|
+
if (connState?.currentAgent)
|
|
1409
|
+
return connState.currentAgent;
|
|
1410
|
+
if (onlineAgents.size === 1)
|
|
1411
|
+
return [...onlineAgents][0];
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
// ─── MCP-001: cello_start_agent handler ───
|
|
1415
|
+
// Bring a registered agent online WITHOUT claiming it as this connection's current agent.
|
|
1416
|
+
handlers.set("cello_start_agent", async (params, _connectionId) => {
|
|
1417
|
+
const name = params?.name;
|
|
1418
|
+
if (!name) {
|
|
1419
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'name' parameter with the agent name to start." };
|
|
1420
|
+
}
|
|
1421
|
+
return startAgentInternal(name);
|
|
1364
1422
|
});
|
|
1365
1423
|
// ─── PERSIST-002 (AC-004): cello_create_agent handler ───
|
|
1366
1424
|
// The explicit agent-creation path: generate a fresh K_local seed, write it as an `agents` row in
|
|
@@ -1612,15 +1670,36 @@ export async function startDaemon(config) {
|
|
|
1612
1670
|
}
|
|
1613
1671
|
const agent = agents.find((a) => a.name === name);
|
|
1614
1672
|
if (!agent || agent.state === "load_failed") {
|
|
1615
|
-
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist.
|
|
1616
|
-
}
|
|
1617
|
-
if (!onlineAgents.has(name)) {
|
|
1618
|
-
return { ok: false, reason: "agent_not_online", guidance: `Agent '${name}' exists but is not online. Call cello_start_agent('${name}') first to bring it online, then retry cello_use_agent.` };
|
|
1673
|
+
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Create it with 'cello create-agent ${name}', register it, then retry — or check names with cello_list_agents.` };
|
|
1619
1674
|
}
|
|
1620
1675
|
const connState = perConnectionState.get(connectionId);
|
|
1621
1676
|
if (!connState) {
|
|
1622
1677
|
return { ok: false, reason: "connection_not_registered", guidance: "Send ipc.connect frame before calling agent tools." };
|
|
1623
1678
|
}
|
|
1679
|
+
// M8C-AUTOSTART-1 (A1): auto-start the agent if it is not online, so the incantation collapses
|
|
1680
|
+
// to `login → use_agent` (no separate cello_start_agent). On a start failure, return a
|
|
1681
|
+
// structured agent_start_failed and leave the current selection UNCHANGED — no half-selected
|
|
1682
|
+
// state (A3). cello_start_agent stays available for bring-online-without-claiming.
|
|
1683
|
+
if (!onlineAgents.has(name)) {
|
|
1684
|
+
logger.info("agent.autostart.attempted", { connectionId, agentName: name });
|
|
1685
|
+
const startRes = startAgentInternal(name);
|
|
1686
|
+
if (!startRes.ok) {
|
|
1687
|
+
// Structured failure envelope (D6). Today `startAgentInternal` is permissive (D12) and its
|
|
1688
|
+
// only failure — agent_not_found — is already caught by the existence pre-check above, so
|
|
1689
|
+
// this branch is currently unreachable. It is the RESERVED structured-failure surface for
|
|
1690
|
+
// when auto-start gains a synchronous failure mode (e.g. D12's reverse: a bounded
|
|
1691
|
+
// waitForSignalingConnected making `directory_unreachable` a real start failure). Kept so
|
|
1692
|
+
// that extension surfaces the reason + guidance here with the selection left unchanged.
|
|
1693
|
+
// See M8C-DECISIONS D12 + BUILD-JOURNAL Entry 8.
|
|
1694
|
+
logger.warn("agent.autostart.failed", { connectionId, agentName: name, reason: startRes.reason });
|
|
1695
|
+
return {
|
|
1696
|
+
ok: false,
|
|
1697
|
+
reason: "agent_start_failed",
|
|
1698
|
+
start_reason: startRes.reason,
|
|
1699
|
+
guidance: `Could not start agent '${name}' to select it: ${startRes.guidance} Your current agent is unchanged.`,
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1624
1703
|
if (connState.currentAgent === name) {
|
|
1625
1704
|
return { ok: false, reason: "agent_already_current", guidance: `Agent '${name}' is already the current agent for this connection. No action needed — you can proceed with session operations.` };
|
|
1626
1705
|
}
|
|
@@ -1630,7 +1709,26 @@ export async function startDaemon(config) {
|
|
|
1630
1709
|
notificationDispatcher.setCurrentAgent(connectionId, name);
|
|
1631
1710
|
notificationDispatcher.dispatchAgentCurrentChanged(connectionId, fromAgent, name);
|
|
1632
1711
|
logger.info("agent.current.switched", { connectionId, fromAgent, toAgent: name });
|
|
1633
|
-
|
|
1712
|
+
// M8C-AUTOSTART-1 (A3, D12): not_registered is a NON-BLOCKING warning — the agent is selected
|
|
1713
|
+
// and usable locally, but it cannot establish directory sessions until registered. Surface the
|
|
1714
|
+
// next step (ONBOARD-NEXTSTEP style) without stranding the selection. One-row read.
|
|
1715
|
+
const result = { ok: true };
|
|
1716
|
+
try {
|
|
1717
|
+
const reg = await new DbRegistrationPersistence({ db: sessionNodeManager.getDb(), agentName: name, logger }).loadRegistrationState();
|
|
1718
|
+
if (!reg || reg.status !== "active") {
|
|
1719
|
+
result["warning"] = "not_registered";
|
|
1720
|
+
result["warning_guidance"] = `Agent '${name}' is now selected but is not registered with the directory — run 'cello register ${name}' to enable sessions with peers. Run 'cello status' to watch registration complete.`;
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
catch (err) {
|
|
1724
|
+
// A failed registration read must not break selection (the agent IS selected). Log the real
|
|
1725
|
+
// reason, and surface a softer `registration_unknown` warning so the operator's surface is NOT
|
|
1726
|
+
// falsely clean — we could not confirm registration, so don't imply it is fine (Finding 3).
|
|
1727
|
+
logger.warn("agent.registration.read.failed", { agentName: name, reason: err instanceof Error ? err.message : String(err) });
|
|
1728
|
+
result["warning"] = "registration_unknown";
|
|
1729
|
+
result["warning_guidance"] = `Agent '${name}' is selected, but its registration status could not be read — run 'cello status' to check whether it is registered with the directory.`;
|
|
1730
|
+
}
|
|
1731
|
+
return result;
|
|
1634
1732
|
});
|
|
1635
1733
|
// ─── MCP-001: cello_list_agents handler ───
|
|
1636
1734
|
handlers.set("cello_list_agents", async (_params, connectionId) => {
|
|
@@ -2268,7 +2366,7 @@ export async function startDaemon(config) {
|
|
|
2268
2366
|
// ─── M8B DOD-REFRESH-1: cello_refresh_shares — proactive share refresh / epoch rollover ───
|
|
2269
2367
|
handlers.set("cello_refresh_shares", async (params, connectionId) => {
|
|
2270
2368
|
const connState = perConnectionState.get(connectionId);
|
|
2271
|
-
const agentName = params?.name
|
|
2369
|
+
const agentName = resolveCurrentAgent(connState, params?.name); // M8C-AUTOSTART-1 F18: sole-online fallback
|
|
2272
2370
|
if (!agentName) {
|
|
2273
2371
|
return { ok: false, reason: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass { name }." };
|
|
2274
2372
|
}
|
|
@@ -2300,7 +2398,7 @@ export async function startDaemon(config) {
|
|
|
2300
2398
|
// ─── M8B DOD-RELAYSIG-1: cello_get_relay_receipts — the agent's stored relay ordering receipts ───
|
|
2301
2399
|
handlers.set("cello_get_relay_receipts", async (params, connectionId) => {
|
|
2302
2400
|
const connState = perConnectionState.get(connectionId);
|
|
2303
|
-
const agentName = params?.name
|
|
2401
|
+
const agentName = resolveCurrentAgent(connState, params?.name); // M8C-AUTOSTART-1 F18: sole-online fallback
|
|
2304
2402
|
if (!agentName) {
|
|
2305
2403
|
return { ok: false, reason: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass { name }." };
|
|
2306
2404
|
}
|
|
@@ -2584,7 +2682,43 @@ export async function startDaemon(config) {
|
|
|
2584
2682
|
if (record.status === "active") {
|
|
2585
2683
|
sealInterruptedInProgress.add(sealKey(record.agent_name, sessionId));
|
|
2586
2684
|
const correlationId = randomUUID();
|
|
2685
|
+
// Fix #1 (cross-node seal-liveness): if this session was brokered by another node, the seal_verified
|
|
2686
|
+
// + session_sealed frames are pushed by that BROKER — but the initiator released its visiting
|
|
2687
|
+
// connection after setup, so on the home stream they never arrive and close times out. Re-open a
|
|
2688
|
+
// transient visiting connection to the broker (seal-capable — openVisitingConnection now wires the
|
|
2689
|
+
// seal handlers) for the duration of the seal, then release it in finally. Same-node sessions have
|
|
2690
|
+
// no entry here and use the home stream unchanged.
|
|
2691
|
+
let sealBrokerConn = null;
|
|
2587
2692
|
try {
|
|
2693
|
+
const brokerNodeForSeal = crossNodeBrokerBySession.get(`${record.agent_name}:${sessionId}`);
|
|
2694
|
+
if (brokerNodeForSeal) {
|
|
2695
|
+
const sealKp = keyProviders.get(record.agent_name);
|
|
2696
|
+
if (!sealKp) {
|
|
2697
|
+
// Fallback-finder #1: never skip the cross-node reconnect silently — without a key provider
|
|
2698
|
+
// we cannot open the broker connection, so the seal will revert to the pre-fix timeout. Log
|
|
2699
|
+
// WHY so it is not indistinguishable from a normal counterparty-didn't-close timeout.
|
|
2700
|
+
logger.warn("session.seal.broker.no_keyprovider", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
|
|
2701
|
+
}
|
|
2702
|
+
else {
|
|
2703
|
+
const sealPubHex = Buffer.from(await sealKp.getPublicKey()).toString("hex");
|
|
2704
|
+
const roster = await resolveConsortiumRoster();
|
|
2705
|
+
const brokerTarget = roster?.find((e) => e.nodeId === brokerNodeForSeal) ?? null;
|
|
2706
|
+
if (!brokerTarget) {
|
|
2707
|
+
logger.warn("session.seal.broker.unresolved", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
|
|
2708
|
+
}
|
|
2709
|
+
else {
|
|
2710
|
+
const conn = openVisitingConnection(record.agent_name, sealKp, sealPubHex, { peerId: brokerTarget.peerId, multiaddr: brokerTarget.multiaddr }, correlationId, brokerNodeForSeal);
|
|
2711
|
+
if (await waitForSignalingConnected(conn.mgr, 10_000)) {
|
|
2712
|
+
sealBrokerConn = conn;
|
|
2713
|
+
logger.info("session.seal.broker.reconnected", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
|
|
2714
|
+
}
|
|
2715
|
+
else {
|
|
2716
|
+
await conn.stop("seal-broker-unreachable");
|
|
2717
|
+
logger.warn("session.seal.broker.unreachable", { agentName: record.agent_name, brokerNode: brokerNodeForSeal, correlationId });
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2588
2722
|
// M7 DOD-SPINE-7: relay-mediated bilateral seal. Submit our SEAL ctrl leaf to the
|
|
2589
2723
|
// relay witness; when the counterparty ALSO closes, the relay's #maybeProcessSeal
|
|
2590
2724
|
// fires → directory processSeal rebuilds + verifies the signed chain → FROST
|
|
@@ -2623,6 +2757,7 @@ export async function startDaemon(config) {
|
|
|
2623
2757
|
pendingSealWaiters.delete(sealKey(record.agent_name, sessionId));
|
|
2624
2758
|
if (sealedCompletion !== null) {
|
|
2625
2759
|
logger.info("session.seal.completed", { sessionId, sealedRoot: sealedCompletion.rootHex, role: "bilateral", correlationId });
|
|
2760
|
+
crossNodeBrokerBySession.delete(`${record.agent_name}:${sessionId}`); // Fix #1 review: evict on terminal seal success (a FAILED close keeps the entry so a retry can still reconnect).
|
|
2626
2761
|
// M7-SESSION-004 (AC-006): return the legibility certificate on the seal completion so
|
|
2627
2762
|
// a reader gets it on the same surface that proves the seal — receipt-not-assent,
|
|
2628
2763
|
// per-party frontiers, attestation modes, and final_message.answered.
|
|
@@ -2698,6 +2833,7 @@ export async function startDaemon(config) {
|
|
|
2698
2833
|
pendingUnilateralWaiters.delete(sessionId);
|
|
2699
2834
|
if (uniResult.ok) {
|
|
2700
2835
|
logger.info("session.seal.completed", { sessionId, sealedRoot: uniResult.sealedRootHex, role: "unilateral", correlationId });
|
|
2836
|
+
crossNodeBrokerBySession.delete(`${record.agent_name}:${sessionId}`); // Fix #1 review: evict on terminal seal success (a FAILED close keeps the entry so a retry can still reconnect).
|
|
2701
2837
|
// M8B FINDING-3 (cascade-2): return the legibility certificate inline — same shape as the
|
|
2702
2838
|
// bilateral close (AC-006) — so the operator gets the receipt on the surface that proves
|
|
2703
2839
|
// the seal, not just a bare root. It is also persisted (above) for cello_get_sealed_receipt.
|
|
@@ -2726,6 +2862,15 @@ export async function startDaemon(config) {
|
|
|
2726
2862
|
};
|
|
2727
2863
|
}
|
|
2728
2864
|
finally {
|
|
2865
|
+
// Fix #1: release the transient broker seal-connection (best-effort; the seal result stands).
|
|
2866
|
+
if (sealBrokerConn) {
|
|
2867
|
+
try {
|
|
2868
|
+
await sealBrokerConn.stop("seal-complete");
|
|
2869
|
+
}
|
|
2870
|
+
catch (err) {
|
|
2871
|
+
logger.warn("session.seal.broker.release_failed", { sessionId, reason: err instanceof Error ? err.message : String(err) });
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2729
2874
|
sealInterruptedInProgress.delete(sealKey(record.agent_name, sessionId));
|
|
2730
2875
|
}
|
|
2731
2876
|
}
|
|
@@ -2762,15 +2907,47 @@ export async function startDaemon(config) {
|
|
|
2762
2907
|
if (!connState || !connState.currentAgent) {
|
|
2763
2908
|
return NO_CURRENT_AGENT_RESPONSE;
|
|
2764
2909
|
}
|
|
2765
|
-
const
|
|
2766
|
-
|
|
2910
|
+
const agentName = connState.currentAgent;
|
|
2911
|
+
const cert = sessionNodeManager.getSealCertificate(agentName, sessionId);
|
|
2912
|
+
if (cert) {
|
|
2913
|
+
return { ok: true, session_id: sessionId, sealed_root: cert.sealed_root, legibility: cert.legibility };
|
|
2914
|
+
}
|
|
2915
|
+
// M8C-INBOX-1 (F4): the single `sealed_receipt_not_found` conflated four distinct causes, so a
|
|
2916
|
+
// caller could not tell a typo from a not-sealed-yet session from a wrong-agent selection.
|
|
2917
|
+
// Split them (full session ids on cello_list_sessions / cello_status already let a pasted id match).
|
|
2918
|
+
if (sessionNodeManager.getSessionRecord(agentName, sessionId)) {
|
|
2919
|
+
// The session is THIS agent's — it simply has no seal certificate yet.
|
|
2920
|
+
return {
|
|
2921
|
+
ok: false,
|
|
2922
|
+
reason: "not_sealed_yet",
|
|
2923
|
+
guidance: "This session exists but is not sealed yet. Close it with cello_close_session and confirm it reports sealed (or wait for the counterparty to co-seal), then retry.",
|
|
2924
|
+
};
|
|
2925
|
+
}
|
|
2926
|
+
// Owned by a DIFFERENT loaded agent → the caller has the wrong current agent selected.
|
|
2927
|
+
const owner = loadedAgents.find((a) => a.name !== agentName && sessionNodeManager.getSessionRecord(a.name, sessionId));
|
|
2928
|
+
if (owner) {
|
|
2929
|
+
return {
|
|
2930
|
+
ok: false,
|
|
2931
|
+
reason: "wrong_agent",
|
|
2932
|
+
guidance: `This session belongs to agent '${owner.name}', not the current agent '${agentName}'. Call cello_use_agent('${owner.name}') then retry cello_get_sealed_receipt.`,
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
// A truncated paste: the id is a strict prefix of one of this agent's real session ids.
|
|
2936
|
+
const truncated = sessionNodeManager
|
|
2937
|
+
.getSessionsForAgent(agentName)
|
|
2938
|
+
.some((s) => s.session_id.startsWith(sessionId) && s.session_id.length > sessionId.length);
|
|
2939
|
+
if (truncated) {
|
|
2767
2940
|
return {
|
|
2768
2941
|
ok: false,
|
|
2769
|
-
reason: "
|
|
2770
|
-
guidance: "
|
|
2942
|
+
reason: "session_id_too_short",
|
|
2943
|
+
guidance: "That looks like a truncated session id. cello_list_sessions and cello status show the FULL id — copy the complete id and retry.",
|
|
2771
2944
|
};
|
|
2772
2945
|
}
|
|
2773
|
-
return {
|
|
2946
|
+
return {
|
|
2947
|
+
ok: false,
|
|
2948
|
+
reason: "unknown_session",
|
|
2949
|
+
guidance: "No session with this id belongs to the current agent. Run cello_list_sessions to see the full ids of this agent's sessions.",
|
|
2950
|
+
};
|
|
2774
2951
|
});
|
|
2775
2952
|
// DOD-LOG-1 (PERSIST-LOG-001): read the durable, decrypted conversation transcript for a session —
|
|
2776
2953
|
// the readable sent+received messages in canonical-sequence order, recovered AFTER a daemon restart
|
|
@@ -3117,6 +3294,32 @@ export async function startDaemon(config) {
|
|
|
3117
3294
|
}
|
|
3118
3295
|
return { ok: true };
|
|
3119
3296
|
});
|
|
3297
|
+
// M8C-INBOX-1: test hook to enqueue a pending inbound session request (mirrors the real inbound
|
|
3298
|
+
// flow's enqueueInboundSession) so cello_check_notifications' pending_session_requests is testable
|
|
3299
|
+
// without standing up the full libp2p inbound path.
|
|
3300
|
+
handlers.set("__test_enqueue_inbound_session", async (params, _connectionId) => {
|
|
3301
|
+
const agentName = params?.agentName;
|
|
3302
|
+
const sessionIdHex = params?.sessionId;
|
|
3303
|
+
const counterpartyPubkeyHex = params?.counterpartyPubkey ?? "";
|
|
3304
|
+
if (!agentName || !sessionIdHex) {
|
|
3305
|
+
return { error: "missing_params", guidance: "Provide agentName and sessionId." };
|
|
3306
|
+
}
|
|
3307
|
+
enqueueInboundSession(agentName, { sessionIdHex, counterpartyPubkeyHex, genesisPrevRootHex: "" });
|
|
3308
|
+
return { ok: true };
|
|
3309
|
+
});
|
|
3310
|
+
// M8C-INBOX-1 (reviewer F1): buffer a received message so a test can drive a live cello_receive
|
|
3311
|
+
// and assert the watermark advances (the N3 delivery-marks-read coupling), without a session tree.
|
|
3312
|
+
handlers.set("__test_buffer_received", async (params, _connectionId) => {
|
|
3313
|
+
const agentName = params?.agentName;
|
|
3314
|
+
const sessionId = params?.sessionId;
|
|
3315
|
+
const seq = params?.seq;
|
|
3316
|
+
const content = params?.content ?? "hello";
|
|
3317
|
+
if (!agentName || !sessionId || typeof seq !== "number") {
|
|
3318
|
+
return { error: "missing_params", guidance: "Provide agentName, sessionId, seq." };
|
|
3319
|
+
}
|
|
3320
|
+
sessionNodeManager.pushReceivedContentForTest(agentName, sessionId, seq, content, params?.senderPubkey ?? "cp");
|
|
3321
|
+
return { ok: true };
|
|
3322
|
+
});
|
|
3120
3323
|
} // end CELLO_ENV=test guard
|
|
3121
3324
|
// ─── M7-SESSION-001 (H-1): seal-interrupted bilateral RESPONDER ────────────
|
|
3122
3325
|
//
|
|
@@ -4224,6 +4427,9 @@ export async function startDaemon(config) {
|
|
|
4224
4427
|
// 1) Deliverable content wins — drain one buffered message per call (FIFO).
|
|
4225
4428
|
const entry = sessionNodeManager.takeReceivedContent(agentName, sessionId);
|
|
4226
4429
|
if (entry) {
|
|
4430
|
+
// M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
|
|
4431
|
+
// message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
|
|
4432
|
+
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
|
|
4227
4433
|
return {
|
|
4228
4434
|
ok: true,
|
|
4229
4435
|
content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
|
|
@@ -4271,6 +4477,45 @@ export async function startDaemon(config) {
|
|
|
4271
4477
|
};
|
|
4272
4478
|
handlers.set("cello_receive", handleReceive);
|
|
4273
4479
|
handlers.set("cello_receive_session", handleReceive);
|
|
4480
|
+
// ─── M8C-INBOX-1 (N1/N4): cello_check_notifications — push-loss reconciler + poll-only inbox ───
|
|
4481
|
+
// Notifications are fire-and-forget (no ack, no redelivery); this is how a client discovers what
|
|
4482
|
+
// it missed while its shim was down/busy, and the primary inbox for poll-only clients (Bedrock,
|
|
4483
|
+
// cron). Content-free: pending session requests (from the in-memory queue, READ non-destructively —
|
|
4484
|
+
// cello_await_session owns draining) + unread message counts (derived from the persisted read
|
|
4485
|
+
// watermark, N2). No separate notification store; no ack verb (N4).
|
|
4486
|
+
handlers.set("cello_check_notifications", async (params, connectionId) => {
|
|
4487
|
+
const connState = perConnectionState.get(connectionId);
|
|
4488
|
+
const scope = params?.scope === "all" ? "all" : "current";
|
|
4489
|
+
let agentNames;
|
|
4490
|
+
if (scope === "all") {
|
|
4491
|
+
agentNames = loadedAgents.map((a) => a.name);
|
|
4492
|
+
}
|
|
4493
|
+
else {
|
|
4494
|
+
// F18: an explicit current agent, else the sole-online agent; ambiguous → no_current_agent.
|
|
4495
|
+
const current = resolveCurrentAgent(connState);
|
|
4496
|
+
if (!current) {
|
|
4497
|
+
return {
|
|
4498
|
+
ok: false,
|
|
4499
|
+
reason: "no_current_agent",
|
|
4500
|
+
guidance: "No current agent for this connection. Call cello_use_agent to select one, or use scope:\"all\" to check every loaded agent.",
|
|
4501
|
+
};
|
|
4502
|
+
}
|
|
4503
|
+
agentNames = [current];
|
|
4504
|
+
}
|
|
4505
|
+
const agents = agentNames.map((agent) => {
|
|
4506
|
+
const pending = (inboundSessionQueues.get(agent) ?? []).map((e) => ({
|
|
4507
|
+
session_id: e.sessionIdHex,
|
|
4508
|
+
from: e.counterpartyPubkeyHex,
|
|
4509
|
+
}));
|
|
4510
|
+
const unread = sessionNodeManager.getUnreadSummary(agent);
|
|
4511
|
+
const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
|
|
4512
|
+
return { agent, pending_session_requests: pending, unread, total_unread };
|
|
4513
|
+
});
|
|
4514
|
+
const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
|
|
4515
|
+
const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
|
|
4516
|
+
logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending });
|
|
4517
|
+
return { ok: true, scope, agents };
|
|
4518
|
+
});
|
|
4274
4519
|
let shutdownPromise = null;
|
|
4275
4520
|
handlers.set("shutdown", async (_params, _connectionId) => {
|
|
4276
4521
|
if (!shutdownPromise) {
|