agents-can-communicate 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -52
- package/SECURITY.md +1 -1
- package/bin/acc-bootstrap.mjs +5 -54
- package/bin/acc-claude-channel.mjs +8 -175
- package/bin/acc-hook.mjs +4 -133
- package/bin/acc-mcp.mjs +4 -59
- package/bin/acc-update-worker.mjs +8 -0
- package/bin/acc.mjs +5 -39
- package/bin/entrypoints/acc-bootstrap.mjs +47 -0
- package/bin/entrypoints/acc-claude-channel.mjs +149 -0
- package/bin/entrypoints/acc-hook.mjs +58 -0
- package/bin/entrypoints/acc-mcp.mjs +70 -0
- package/bin/entrypoints/acc.mjs +51 -0
- package/bin/entrypoints/claude-channel-stdio.mjs +29 -0
- package/bin/entrypoints/hook-output.mjs +77 -0
- package/docs/ADAPTER_AUTHORING.md +52 -8
- package/docs/ARCHITECTURE.md +64 -11
- package/docs/CAPABILITIES.md +41 -37
- package/docs/CLI.md +145 -25
- package/docs/CONCEPTS.md +13 -6
- package/docs/CONFIGURATION.md +37 -22
- package/docs/DESIGN_DECISIONS.md +16 -11
- package/docs/GETTING_STARTED.md +41 -7
- package/docs/GLOSSARY.md +9 -3
- package/docs/HOW_IT_WORKS.md +32 -11
- package/docs/MCP.md +91 -24
- package/docs/PROTOCOL.md +69 -12
- package/docs/RELEASING.md +28 -10
- package/docs/SECURITY_MODEL.md +47 -13
- package/docs/TROUBLESHOOTING.md +102 -20
- package/docs/UPGRADING.md +154 -0
- package/docs/WHY_ACC.md +5 -4
- package/docs/index.md +8 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +106 -5
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +6 -6
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-codex/certification.json +38 -13
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +220 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product-evidence.json +1549 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-current-binding-product.json +27 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product-evidence.json +1549 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-product.json +27 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport-evidence.json +259 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.1-local-daemon-transport.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product-evidence.json +1549 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-current-binding-product.json +27 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product-evidence.json +1549 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-product.json +27 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport-evidence.json +259 -0
- package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.153.4-local-daemon-transport.json +21 -0
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +14 -2
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +106 -5
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +26 -44
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +70 -17
- package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +231 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +71 -63
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +107 -103
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +96 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +3 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +106 -5
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +122 -8
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +5 -3
- package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +9 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +106 -5
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +5 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +26 -4
- package/node_modules/@agents-can-communicate/adapter-sdk/src/decision-text.mjs +19 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +5 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +9 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +2 -6
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +29 -9
- package/node_modules/@agents-can-communicate/adapter-sdk/src/toml-block.mjs +8 -6
- package/node_modules/@agents-can-communicate/cli/package.json +4 -2
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +49 -22
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +11 -4
- package/node_modules/@agents-can-communicate/cli/src/git-probe.mjs +9 -3
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +109 -14
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +24 -5
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +35 -12
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +110 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +64 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +71 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +110 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +167 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +78 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +60 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/leases.mjs +60 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/location.mjs +55 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +145 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/policy.mjs +4 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +40 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +21 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +123 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +65 -0
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +33 -108
- package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +25 -7
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/attention.mjs +3 -0
- package/node_modules/@agents-can-communicate/core/src/claims.mjs +13 -8
- package/node_modules/@agents-can-communicate/core/src/conversations.mjs +9 -25
- package/node_modules/@agents-can-communicate/core/src/decision-changes.mjs +36 -0
- package/node_modules/@agents-can-communicate/core/src/decision-state.mjs +42 -0
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +11 -4
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +48 -17
- package/node_modules/@agents-can-communicate/core/src/intents.mjs +35 -15
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +7 -5
- package/node_modules/@agents-can-communicate/core/src/message-pages.mjs +75 -0
- package/node_modules/@agents-can-communicate/core/src/next-turn-delivery.mjs +31 -0
- package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +128 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +58 -132
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +31 -2
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +38 -0
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +86 -10
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +1 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +6 -4
- package/node_modules/@agents-can-communicate/hook-runner/src/native-retirement.mjs +48 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +30 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +172 -67
- package/node_modules/@agents-can-communicate/hook-runner/src/session-lifecycle.mjs +13 -0
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +17 -2
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +11 -5
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +1 -1
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +21 -0
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +20 -6
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +10 -4
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +3 -3
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/protocol.mjs +85 -0
- package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +3 -2
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +59 -57
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +47 -33
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +3 -0
- package/node_modules/@agents-can-communicate/protocol/src/decision-changes.mjs +15 -0
- package/node_modules/@agents-can-communicate/protocol/src/index.mjs +3 -1
- package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +14 -5
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +5 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/deadline.mjs +7 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +2 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/session-read.mjs +25 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +28 -25
- package/package.json +2 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { AccError, EXIT, SCHEMA_VERSION
|
|
1
|
+
import { AccError, EXIT, SCHEMA_VERSION }
|
|
2
2
|
from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
|
-
import { ensureMaterialised, isMaterialised
|
|
4
|
+
import { ensureMaterialised, isMaterialised } from "./materialisation.mjs";
|
|
5
|
+
import { createSessionOpener } from "./session-opening.mjs";
|
|
5
6
|
|
|
6
7
|
// A hook-only adapter heartbeats only when its harness gives it a turn, so the
|
|
7
8
|
// staleness window is a multiple of the cadence the session itself declared
|
|
@@ -50,70 +51,25 @@ export function classifySessionPresence(session, now, pidIsAlive) {
|
|
|
50
51
|
return age > UNKNOWN_EXPIRY_MS ? "offline" : ageBand(session, age);
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const sessionRecord = (input, now, generation) => validateRecord("session", {
|
|
54
|
-
schemaVersion: SCHEMA_VERSION,
|
|
55
|
-
sessionId: input.sessionId,
|
|
56
|
-
participantId: input.participantId,
|
|
57
|
-
workspaceId: input.workspaceId,
|
|
58
|
-
generation,
|
|
59
|
-
harness: input.harness,
|
|
60
|
-
state: "open",
|
|
61
|
-
parentSessionId: input.parentSessionId ?? null,
|
|
62
|
-
checkoutRoot: input.checkoutRoot ?? null,
|
|
63
|
-
branch: input.branch ?? null,
|
|
64
|
-
pid: input.pid ?? null,
|
|
65
|
-
// Both default to the weaker reading. A session that declares nothing is a
|
|
66
|
-
// session nothing intercepts - an MCP client, or a CLI user - and claiming
|
|
67
|
-
// otherwise would promise enforcement that is not there.
|
|
68
|
-
enforcement: input.enforcement ?? "advisory",
|
|
69
|
-
lifecycle: input.lifecycle ?? "manual",
|
|
70
|
-
heartbeatCadenceMs: input.heartbeatCadenceMs,
|
|
71
|
-
startedAt: now,
|
|
72
|
-
heartbeatAt: now,
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const participantRecord = (input, now) => validateRecord("participant", {
|
|
76
|
-
schemaVersion: SCHEMA_VERSION,
|
|
77
|
-
participantId: input.participantId,
|
|
78
|
-
workspaceId: input.workspaceId,
|
|
79
|
-
displayName: input.displayName ?? input.participantId,
|
|
80
|
-
kind: input.participantKind ?? "agent",
|
|
81
|
-
createdAt: now,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
54
|
export function createSessionService(ports) {
|
|
85
55
|
const { store, clock, ids, pidIsAlive } = ports;
|
|
86
|
-
const workspaceOf = input => input.workspaceId ?? store.workspaceId;
|
|
87
56
|
|
|
88
57
|
async function locate(sessionId, workspaceId) {
|
|
89
58
|
const ephemeral = await store.ephemeral.get("session", sessionId);
|
|
90
|
-
|
|
91
|
-
const resolved = workspaceId ?? store.workspaceId;
|
|
59
|
+
const resolved = workspaceId ?? store.workspaceId ?? ephemeral?.workspaceId;
|
|
92
60
|
if (resolved === undefined) return null;
|
|
93
61
|
const durable = (await store.snapshot(resolved, { kinds: ["session"] })).sessions
|
|
94
62
|
.find(session => session.sessionId === sessionId) ?? null;
|
|
95
|
-
|
|
63
|
+
if (durable !== null) return { record: durable, durable: true };
|
|
64
|
+
return ephemeral !== null && ephemeral.workspaceId === resolved
|
|
65
|
+
? { record: ephemeral, durable: false } : null;
|
|
96
66
|
}
|
|
97
67
|
|
|
98
|
-
function
|
|
99
|
-
if (existing.record.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// self-corrects the moment that session next takes a turn. A wrong
|
|
103
|
-
// replacement does not self-correct - it takes the generation, and the
|
|
104
|
-
// original session's own heartbeats fail with CONFLICT from then on. So
|
|
105
|
-
// only a pid confirmed dead is authority to replace; "we cannot tell" -
|
|
106
|
-
// a session with no recorded pid - is never enough, however long the
|
|
107
|
-
// silence.
|
|
108
|
-
const live = probe ?? (record => (record.pid ?? null) === null
|
|
109
|
-
|| pidIsAlive(record.pid));
|
|
110
|
-
if (live(existing.record)) {
|
|
111
|
-
throw new AccError(EXIT.CONFLICT, "the session id is already live",
|
|
112
|
-
{ sessionId: existing.record.sessionId });
|
|
68
|
+
function assertGeneration(existing, generation, action, workspaceId) {
|
|
69
|
+
if (workspaceId !== undefined && existing.record.workspaceId !== workspaceId) {
|
|
70
|
+
throw new AccError(EXIT.CONFLICT, "session is not open in this workspace",
|
|
71
|
+
{ sessionId: existing.record.sessionId, workspaceId });
|
|
113
72
|
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function assertGeneration(existing, generation, action) {
|
|
117
73
|
if (existing.record.generation !== generation) {
|
|
118
74
|
throw new AccError(EXIT.CONFLICT, `cannot ${action} a replaced session generation`,
|
|
119
75
|
{ sessionId: existing.record.sessionId, expected: generation,
|
|
@@ -121,66 +77,28 @@ export function createSessionService(ports) {
|
|
|
121
77
|
}
|
|
122
78
|
}
|
|
123
79
|
|
|
124
|
-
async function openSession(input) {
|
|
125
|
-
const workspaceId = workspaceOf(input);
|
|
126
|
-
assertPortableId(workspaceId, "workspace id");
|
|
127
|
-
assertPortableId(input.participantId, "participant id");
|
|
128
|
-
const sessionId = input.sessionId ?? createId("session");
|
|
129
|
-
const existing = await locate(sessionId, workspaceId);
|
|
130
|
-
if (existing !== null) assertReplaceable(existing, input.probe);
|
|
131
|
-
|
|
132
|
-
const now = clock.now();
|
|
133
|
-
const session = sessionRecord({ ...input, workspaceId, sessionId }, now,
|
|
134
|
-
ids.next("generation"));
|
|
135
|
-
const participant = participantRecord({ ...input, workspaceId }, now);
|
|
136
|
-
|
|
137
|
-
if (await isMaterialised(store, workspaceId)) {
|
|
138
|
-
await store.transaction(async tx => {
|
|
139
|
-
const replaced = tx.get("session", sessionId)?.generation ?? null;
|
|
140
|
-
if (tx.get("participant", participant.participantId) === null) {
|
|
141
|
-
tx.put("participant", participant.participantId, participant);
|
|
142
|
-
}
|
|
143
|
-
tx.put("session", sessionId, session, tx.generationOf("session", sessionId));
|
|
144
|
-
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
|
|
145
|
-
actorSessionId: sessionId, type: "session.opened", occurredAt: now,
|
|
146
|
-
payload: { replaced } });
|
|
147
|
-
}, { kinds: ["participant", "session"] });
|
|
148
|
-
return session;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
await store.ephemeral.put("participant", participant.participantId, participant);
|
|
152
|
-
await store.ephemeral.put("session", sessionId, session);
|
|
153
|
-
// The approved trigger is the SECOND live session, not the first: a lone
|
|
154
|
-
// session must be able to open and close without leaving a trace.
|
|
155
|
-
const live = (await store.ephemeral.list("session")).filter(item => item.state === "open");
|
|
156
|
-
// Recheck durable state only after our ephemeral writes. Another process
|
|
157
|
-
// may have staged the old ephemeral set after our first precheck, committed
|
|
158
|
-
// materialisation, and retired that set before these puts completed. In
|
|
159
|
-
// that case this attach is the only ephemeral session left, but it still
|
|
160
|
-
// has to run the idempotent promotion pass into the now-durable workspace.
|
|
161
|
-
if (live.length > 1 || await isMaterialised(store, workspaceId)) {
|
|
162
|
-
await materialise(ports, { workspaceId, descriptor: input.descriptor,
|
|
163
|
-
reason: "second_live_session" });
|
|
164
|
-
}
|
|
165
|
-
return session;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
80
|
async function heartbeatSession({ sessionId, workspaceId, generation }) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
81
|
+
const beat = current => {
|
|
82
|
+
if (current === null) return null;
|
|
83
|
+
assertGeneration({ record: current }, generation, "heartbeat", workspaceId ?? store.workspaceId);
|
|
84
|
+
if (current.state !== "open") {
|
|
85
|
+
throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
86
|
+
}
|
|
87
|
+
return { ...current, heartbeatAt: clock.now() };
|
|
88
|
+
};
|
|
174
89
|
// Heartbeats never append to the semantic event feed: only open, close, and
|
|
175
|
-
// presence transitions surface through cursor sync
|
|
176
|
-
|
|
177
|
-
|
|
90
|
+
// presence transitions surface through cursor sync. Read and validate under
|
|
91
|
+
// the writer lock, so a pending heartbeat cannot restore an old generation.
|
|
92
|
+
// Promoted copies may await cleanup; their durable record already owns writes.
|
|
93
|
+
const ephemeral = await store.ephemeral.update("session", sessionId, async current =>
|
|
94
|
+
current !== null && !await isMaterialised(store, current.workspaceId) ? beat(current) : null);
|
|
95
|
+
if (ephemeral !== null) return ephemeral;
|
|
96
|
+
return store.transaction(tx => {
|
|
97
|
+
const beaten = beat(tx.get("session", sessionId));
|
|
98
|
+
if (beaten === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
99
|
+
tx.put("session", sessionId, beaten, tx.generationOf("session", sessionId));
|
|
178
100
|
return beaten;
|
|
179
|
-
}
|
|
180
|
-
await store.transaction(async tx =>
|
|
181
|
-
tx.put("session", sessionId, beaten, tx.generationOf("session", sessionId)),
|
|
182
|
-
{ kinds: ["session"] });
|
|
183
|
-
return beaten;
|
|
101
|
+
}, { kinds: ["session"] });
|
|
184
102
|
}
|
|
185
103
|
|
|
186
104
|
/**
|
|
@@ -196,9 +114,11 @@ export function createSessionService(ports) {
|
|
|
196
114
|
* not a second agent arriving.
|
|
197
115
|
*/
|
|
198
116
|
async function resumeSession({ sessionId, workspaceId, generation, ...metadata }) {
|
|
117
|
+
const resolvedWorkspace = workspaceId ?? store.workspaceId;
|
|
199
118
|
const resume = current => {
|
|
200
119
|
if (current === null || current.state !== "open"
|
|
201
|
-
|| current.generation !== generation
|
|
120
|
+
|| current.generation !== generation
|
|
121
|
+
|| resolvedWorkspace !== undefined && current.workspaceId !== resolvedWorkspace) return null;
|
|
202
122
|
return { ...current,
|
|
203
123
|
pid: metadata.pid ?? null,
|
|
204
124
|
checkoutRoot: metadata.checkoutRoot ?? current.checkoutRoot,
|
|
@@ -213,13 +133,13 @@ export function createSessionService(ports) {
|
|
|
213
133
|
// The compare and replacement happen under the ephemeral store's writer
|
|
214
134
|
// lock. A close or a replacement generation can win before this update or
|
|
215
135
|
// after it, but can never be overwritten from a record read beforehand.
|
|
216
|
-
const ephemeral = await store.ephemeral.update("session", sessionId,
|
|
136
|
+
const ephemeral = await store.ephemeral.update("session", sessionId, async current =>
|
|
137
|
+
current !== null && !await isMaterialised(store, current.workspaceId) ? resume(current) : null);
|
|
217
138
|
if (ephemeral !== null) return ephemeral;
|
|
218
139
|
|
|
219
140
|
// Re-read and validate inside the durable transaction for the same reason.
|
|
220
141
|
// Using generationOf only as the put token is insufficient: it protects
|
|
221
142
|
// the envelope write, not the semantic generation carried by the record.
|
|
222
|
-
const resolvedWorkspace = workspaceId ?? store.workspaceId;
|
|
223
143
|
if (resolvedWorkspace === undefined) return null;
|
|
224
144
|
return store.transaction(async tx => {
|
|
225
145
|
const current = tx.get("session", sessionId);
|
|
@@ -231,27 +151,33 @@ export function createSessionService(ports) {
|
|
|
231
151
|
}
|
|
232
152
|
|
|
233
153
|
async function closeSession({ sessionId, workspaceId, generation }) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
154
|
+
const close = current => {
|
|
155
|
+
assertGeneration({ record: current }, generation, "close", workspaceId ?? store.workspaceId);
|
|
156
|
+
return { ...current, state: "closed", heartbeatAt: clock.now() };
|
|
157
|
+
};
|
|
158
|
+
let ephemeral = null;
|
|
159
|
+
await store.ephemeral.delete("session", sessionId, async current => {
|
|
160
|
+
if (await isMaterialised(store, current.workspaceId)) return false;
|
|
161
|
+
ephemeral = close(current);
|
|
162
|
+
return true;
|
|
163
|
+
});
|
|
164
|
+
if (ephemeral !== null) {
|
|
165
|
+
// Cleanup is a separate locked write; a successor may have appeared since
|
|
166
|
+
// the close. Its intent must survive the old session's delayed cleanup.
|
|
167
|
+
await store.ephemeral.delete("intent", sessionId,
|
|
168
|
+
async () => await store.ephemeral.get("session", sessionId) === null);
|
|
169
|
+
return ephemeral;
|
|
246
170
|
}
|
|
247
|
-
|
|
248
|
-
|
|
171
|
+
return store.transaction(tx => {
|
|
172
|
+
const current = tx.get("session", sessionId);
|
|
173
|
+
if (current === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
174
|
+
const closed = close(current);
|
|
249
175
|
tx.put("session", sessionId, closed, tx.generationOf("session", sessionId));
|
|
250
176
|
tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
|
|
251
177
|
workspaceId: closed.workspaceId, actorSessionId: sessionId, type: "session.closed",
|
|
252
|
-
occurredAt:
|
|
178
|
+
occurredAt: closed.heartbeatAt, payload: {} });
|
|
179
|
+
return closed;
|
|
253
180
|
}, { kinds: ["session"] });
|
|
254
|
-
return closed;
|
|
255
181
|
}
|
|
256
182
|
|
|
257
183
|
async function listLiveSessions({ participantId, workspaceId, now = clock.now() }) {
|
|
@@ -269,7 +195,7 @@ export function createSessionService(ports) {
|
|
|
269
195
|
}
|
|
270
196
|
|
|
271
197
|
return {
|
|
272
|
-
openSession,
|
|
198
|
+
openSession: createSessionOpener(ports, locate),
|
|
273
199
|
resumeSession,
|
|
274
200
|
heartbeatSession,
|
|
275
201
|
closeSession,
|
|
@@ -2,10 +2,12 @@ import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
|
2
2
|
|
|
3
3
|
import { computeAttention } from "./attention.mjs";
|
|
4
4
|
import { classifySessionPresence } from "./sessions.mjs";
|
|
5
|
+
import { assertMessageId, messagePage } from "./message-pages.mjs";
|
|
6
|
+
import { decisionView, isCurrentDecision } from "./decision-state.mjs";
|
|
5
7
|
|
|
6
8
|
const DEFAULT_LIMIT = 100;
|
|
7
9
|
const CURSOR = /^[0-9]{16}$/;
|
|
8
|
-
const SCOPES = Object.freeze(["delta", "full"]);
|
|
10
|
+
const SCOPES = Object.freeze(["delta", "full", "history"]);
|
|
9
11
|
|
|
10
12
|
function assertScope(scope) {
|
|
11
13
|
if (scope != null && !SCOPES.includes(scope)) {
|
|
@@ -25,9 +27,36 @@ function assertCursor(cursor) {
|
|
|
25
27
|
export function createSyncService(ports, sessions) {
|
|
26
28
|
const { store, clock, pidIsAlive } = ports;
|
|
27
29
|
|
|
30
|
+
async function history(input) {
|
|
31
|
+
const exact = input.messageId !== undefined;
|
|
32
|
+
if (input.current !== undefined && (typeof input.current !== "boolean"
|
|
33
|
+
|| input.kind !== "decision" || exact)) {
|
|
34
|
+
throw new AccError(EXIT.USAGE, "current requires a decision history list, without message id");
|
|
35
|
+
}
|
|
36
|
+
if (exact && (input.cursor != null || input.limit !== undefined || input.kind !== undefined)) {
|
|
37
|
+
throw new AccError(EXIT.USAGE, "an exact history read cannot use cursor, limit, or type");
|
|
38
|
+
}
|
|
39
|
+
if (exact) assertMessageId(input.messageId);
|
|
40
|
+
const snapshot = await store.snapshot(input.workspaceId ?? store.workspaceId,
|
|
41
|
+
{ kinds: ["message"] });
|
|
42
|
+
const messages = snapshot.messages.map(decisionView(snapshot.messages));
|
|
43
|
+
if (!exact) return messagePage(messages, input,
|
|
44
|
+
{ include: message => input.current !== true || isCurrentDecision(message),
|
|
45
|
+
metadata: { scope: "history", view: "summary" } });
|
|
46
|
+
const message = messages.find(item => item.messageId === input.messageId);
|
|
47
|
+
if (message === undefined) {
|
|
48
|
+
throw new AccError(EXIT.DATA, "message is not in this workspace's history");
|
|
49
|
+
}
|
|
50
|
+
return { scope: "history", view: "message", items: [message], nextCursor: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
async function sync(input = {}) {
|
|
29
|
-
if (input.cursor != null) assertCursor(input.cursor);
|
|
30
54
|
assertScope(input.scope);
|
|
55
|
+
if (input.scope === "history") return history(input);
|
|
56
|
+
if (input.messageId !== undefined || input.kind !== undefined || input.current !== undefined) {
|
|
57
|
+
throw new AccError(EXIT.USAGE, "message, type and current require history scope");
|
|
58
|
+
}
|
|
59
|
+
if (input.cursor != null) assertCursor(input.cursor);
|
|
31
60
|
const workspaceId = input.workspaceId ?? store.workspaceId;
|
|
32
61
|
const now = clock.now();
|
|
33
62
|
const located = input.sessionId === undefined
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
|
|
3
|
+
const MAX_REFRESH_LEASE_MS = 120_000;
|
|
4
|
+
|
|
5
|
+
function sameBinding(current, expected, leaseUntil) {
|
|
6
|
+
return current !== null && current !== undefined
|
|
7
|
+
&& (current.retiredAt === null || current.retiredAt === undefined)
|
|
8
|
+
&& current.sessionId === expected.sessionId
|
|
9
|
+
&& current.generation === expected.generation
|
|
10
|
+
&& current.adapterId === expected.adapterId
|
|
11
|
+
&& current.clientVersion === expected.clientVersion
|
|
12
|
+
&& current.opaqueEndpointRef === expected.opaqueEndpointRef
|
|
13
|
+
&& current.leaseUntil === leaseUntil;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function refreshExpiredBinding({ service, adapter, binding, runtimeDir,
|
|
17
|
+
platform, clock, timeoutMs = 750 }) {
|
|
18
|
+
if (typeof adapter?.refreshNativeSession !== "function") return false;
|
|
19
|
+
const now = Date.parse(clock.now());
|
|
20
|
+
if (!Number.isFinite(now)) return false;
|
|
21
|
+
try {
|
|
22
|
+
const handshake = await adapter.refreshNativeSession({ binding, runtimeDir, timeoutMs });
|
|
23
|
+
const validated = validateNativeHandshake(adapter, {
|
|
24
|
+
clientVersion: binding.clientVersion, platform, handshake,
|
|
25
|
+
});
|
|
26
|
+
if (!validated.ok || validated.opaqueEndpointRef !== binding.opaqueEndpointRef) return false;
|
|
27
|
+
const requestedLease = Date.parse(validated.leaseUntil);
|
|
28
|
+
if (!Number.isFinite(requestedLease) || requestedLease <= now) return false;
|
|
29
|
+
const leaseUntil = new Date(Math.min(requestedLease,
|
|
30
|
+
now + MAX_REFRESH_LEASE_MS)).toISOString();
|
|
31
|
+
await service.refreshDeliveryBinding({ sessionId: binding.sessionId,
|
|
32
|
+
generation: binding.generation, leaseUntil });
|
|
33
|
+
const current = await service.store?.ephemeral?.get("deliveryBinding", binding.sessionId);
|
|
34
|
+
return sameBinding(current, binding, leaseUntil);
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { refreshExpiredBinding } from "./refresh-binding.mjs";
|
|
2
|
+
|
|
1
3
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
2
4
|
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
|
|
3
5
|
"unsupported_client_version"]);
|
|
@@ -13,6 +15,7 @@ const adaptersById = adapters => adapters instanceof Map
|
|
|
13
15
|
// transfers it. A note informs and waits for the next turn. Room messages
|
|
14
16
|
// have no recipient and are never offered live.
|
|
15
17
|
const ACTIONABLE = new Set(["question", "request", "answer", "decision", "handoff"]);
|
|
18
|
+
const LIVE_POLICIES = new Set(["off", "actionable", "all"]);
|
|
16
19
|
const permits = (policy, kind) => policy === "all"
|
|
17
20
|
|| (policy === "actionable" && ACTIONABLE.has(kind));
|
|
18
21
|
|
|
@@ -39,9 +42,22 @@ function safeTransport(value, opaqueEndpointRef) {
|
|
|
39
42
|
return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
export function createDeliveryRouter({ service, adapters, clock }) {
|
|
45
|
+
export function createDeliveryRouter({ service, adapters, clock, platform, readLivePolicy }) {
|
|
43
46
|
const registry = adaptersById(adapters);
|
|
44
47
|
|
|
48
|
+
async function policyFor(adapter, binding) {
|
|
49
|
+
if (adapter?.nativeDelivery?.policySource !== "installation-record") {
|
|
50
|
+
return LIVE_POLICIES.has(binding.livePolicy) ? binding.livePolicy : "off";
|
|
51
|
+
}
|
|
52
|
+
if (typeof readLivePolicy !== "function") return "off";
|
|
53
|
+
try {
|
|
54
|
+
const policy = await readLivePolicy({ adapter, binding });
|
|
55
|
+
return LIVE_POLICIES.has(policy) ? policy : "off";
|
|
56
|
+
} catch {
|
|
57
|
+
return "off";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
async function recordFailure(binding, message, participantId, transport, safeErrorCode) {
|
|
46
62
|
await service.recordOfferFailed({ messageId: message.messageId,
|
|
47
63
|
recipientParticipantId: participantId, targetSessionId: binding.sessionId,
|
|
@@ -54,24 +70,29 @@ export function createDeliveryRouter({ service, adapters, clock }) {
|
|
|
54
70
|
const receipt = await service.readReceipt({ messageId: message.messageId,
|
|
55
71
|
recipientParticipantId: participantId });
|
|
56
72
|
if (receipt.state !== "queued") return settled(receipt);
|
|
73
|
+
// Core keeps its existing 24-hour presence hard expiry. Refreshing an
|
|
74
|
+
// endpoint lease never sends a hook heartbeat: a daemon is reachability,
|
|
75
|
+
// not evidence that this particular session thread is still present.
|
|
57
76
|
const liveSessions = await service.listLiveSessions({ participantId, now });
|
|
58
77
|
if (liveSessions.length === 0) return durable(participantId, "recipient_unavailable");
|
|
59
78
|
if (liveSessions.length > 1) {
|
|
60
79
|
return durable(participantId, "ambiguous_recipient_sessions");
|
|
61
80
|
}
|
|
62
81
|
const [target] = liveSessions;
|
|
63
|
-
const bindings = (await service.listDeliveryBindings({
|
|
82
|
+
const bindings = (await service.listDeliveryBindings({
|
|
83
|
+
participantId, now, includeExpired: true }))
|
|
64
84
|
.filter(binding => binding.sessionId === target.sessionId
|
|
65
85
|
&& binding.generation === target.generation);
|
|
66
86
|
if (bindings.length === 0) return durable(participantId, "recipient_unavailable");
|
|
67
|
-
const
|
|
68
|
-
|
|
87
|
+
const evaluated = await Promise.all(bindings.map(async binding => {
|
|
88
|
+
const adapter = registry.get(binding.adapterId);
|
|
89
|
+
return { binding, adapter, policy: await policyFor(adapter, binding) };
|
|
90
|
+
}));
|
|
91
|
+
const permitted = evaluated.filter(({ policy }) => permits(policy, message.kind));
|
|
69
92
|
if (permitted.length === 0) return durable(participantId, "delivery_disabled");
|
|
70
|
-
const reachable = permitted.filter(binding => binding.availableModes.includes("livePush"));
|
|
93
|
+
const reachable = permitted.filter(({ binding }) => binding.availableModes.includes("livePush"));
|
|
71
94
|
if (reachable.length === 0) return durable(participantId, "recipient_unavailable");
|
|
72
|
-
const capable = reachable.
|
|
73
|
-
adapter: registry.get(binding.adapterId) }))
|
|
74
|
-
.filter(({ binding, adapter }) => liveCapable(adapter, binding));
|
|
95
|
+
const capable = reachable.filter(({ binding, adapter }) => liveCapable(adapter, binding));
|
|
75
96
|
if (capable.length === 0) {
|
|
76
97
|
return durable(participantId, "unsupported_client_version");
|
|
77
98
|
}
|
|
@@ -79,13 +100,61 @@ export function createDeliveryRouter({ service, adapters, clock }) {
|
|
|
79
100
|
return durable(participantId, "ambiguous_recipient_sessions");
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
|
|
103
|
+
let { binding } = capable[0];
|
|
104
|
+
const { adapter } = capable[0];
|
|
105
|
+
if (Date.parse(binding.leaseUntil) <= Date.parse(now)) {
|
|
106
|
+
const refreshed = await refreshExpiredBinding({ service, adapter, binding,
|
|
107
|
+
runtimeDir: service.store?.root, platform, clock });
|
|
108
|
+
if (!refreshed) return durable(participantId, "recipient_unavailable");
|
|
109
|
+
const current = (await service.listDeliveryBindings({
|
|
110
|
+
participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
|
|
111
|
+
&& item.generation === binding.generation
|
|
112
|
+
&& item.adapterId === binding.adapterId
|
|
113
|
+
&& item.clientVersion === binding.clientVersion
|
|
114
|
+
&& item.opaqueEndpointRef === binding.opaqueEndpointRef);
|
|
115
|
+
if (current.length !== 1) return durable(participantId, "recipient_unavailable");
|
|
116
|
+
[binding] = current;
|
|
117
|
+
}
|
|
118
|
+
const currentPolicy = await policyFor(adapter, binding);
|
|
119
|
+
if (!permits(currentPolicy, message.kind)) {
|
|
120
|
+
return durable(participantId, "delivery_disabled");
|
|
121
|
+
}
|
|
122
|
+
// A refresh RPC or policy read can overlap another session opening. The
|
|
123
|
+
// receiver must still be the sole live session when we hand off to transport.
|
|
124
|
+
const currentSessions = await service.listLiveSessions({ participantId, now: clock.now() });
|
|
125
|
+
if (currentSessions.length > 1) return durable(participantId, "ambiguous_recipient_sessions");
|
|
126
|
+
if (currentSessions.length !== 1 || currentSessions[0].sessionId !== binding.sessionId
|
|
127
|
+
|| currentSessions[0].generation !== binding.generation) {
|
|
128
|
+
return durable(participantId, "recipient_unavailable");
|
|
129
|
+
}
|
|
130
|
+
// Every recipient gets a fresh decision snapshot after async lookup. A
|
|
131
|
+
// previous recipient may have replaced it while processing their offer.
|
|
132
|
+
// A send already in flight cannot be recalled by a later peer assertion.
|
|
133
|
+
if (message.kind === "decision") {
|
|
134
|
+
message = (await service.sync({ scope: "history", messageId: message.messageId })).items[0];
|
|
135
|
+
if (!message.decisionStatus.isHead) return settled(await service.readReceipt({
|
|
136
|
+
messageId: message.messageId, recipientParticipantId: participantId }));
|
|
137
|
+
}
|
|
138
|
+
// Consent and session reads can outlive retirement or a re-handshake in
|
|
139
|
+
// the same generation. Core filters retired, expired and stale-generation
|
|
140
|
+
// bindings; require the selected identity and live mode immediately before
|
|
141
|
+
// transport, with no further awaited policy/session work in between.
|
|
142
|
+
const currentBindings = (await service.listDeliveryBindings({
|
|
143
|
+
participantId, now: clock.now() })).filter(item => item.sessionId === binding.sessionId
|
|
144
|
+
&& item.generation === binding.generation
|
|
145
|
+
&& item.adapterId === binding.adapterId
|
|
146
|
+
&& item.clientVersion === binding.clientVersion
|
|
147
|
+
&& item.opaqueEndpointRef === binding.opaqueEndpointRef
|
|
148
|
+
&& item.availableModes.includes("livePush")
|
|
149
|
+
&& Date.parse(item.leaseUntil) > Date.parse(clock.now()));
|
|
150
|
+
if (currentBindings.length !== 1) return durable(participantId, "recipient_unavailable");
|
|
151
|
+
const currentBinding = { ...currentBindings[0], livePolicy: currentPolicy };
|
|
83
152
|
let response;
|
|
84
153
|
try {
|
|
85
154
|
// The store root is this workspace's runtime dir; the adapter resolves its
|
|
86
155
|
// opaque endpoint id under it. Passed as data, never as a leak into core:
|
|
87
156
|
// the router does not read what the adapter does with it.
|
|
88
|
-
response = await adapter.offerMessage({ binding, message,
|
|
157
|
+
response = await adapter.offerMessage({ binding: currentBinding, message,
|
|
89
158
|
runtimeDir: service.store?.root });
|
|
90
159
|
} catch {
|
|
91
160
|
await recordFailure(binding, message, participantId, "live-adapter", "transport_error");
|
|
@@ -119,6 +188,13 @@ export function createDeliveryRouter({ service, adapters, clock }) {
|
|
|
119
188
|
if (!Array.isArray(message?.toParticipantIds) || message.toParticipantIds.length === 0) {
|
|
120
189
|
return [];
|
|
121
190
|
}
|
|
191
|
+
if (message.kind === "decision") {
|
|
192
|
+
message = (await service.sync({ scope: "history", messageId: message.messageId })).items[0];
|
|
193
|
+
if (!message.decisionStatus.isHead) {
|
|
194
|
+
return Promise.all(message.toParticipantIds.map(async recipientParticipantId => settled(
|
|
195
|
+
await service.readReceipt({ messageId: message.messageId, recipientParticipantId }))));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
122
198
|
const now = clock.now();
|
|
123
199
|
const outcomes = [];
|
|
124
200
|
for (const participantId of message.toParticipantIds) {
|
|
@@ -11,6 +11,7 @@ export function probeClientVersion(adapter, { timeoutMs = 1_000 } = {}) {
|
|
|
11
11
|
return new Promise(resolve => {
|
|
12
12
|
execFile(adapter.client.command, adapter.client.versionArgs ?? ["--version"], {
|
|
13
13
|
timeout: timeoutMs,
|
|
14
|
+
killSignal: "SIGKILL",
|
|
14
15
|
windowsHide: true,
|
|
15
16
|
}, (error, stdout, stderr) => {
|
|
16
17
|
if (error !== null) return resolve(null);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateNativeHandshake } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { EXIT } from "@agents-can-communicate/protocol";
|
|
3
|
+
import { retireNativeBinding } from "./native-retirement.mjs";
|
|
3
4
|
|
|
4
5
|
// The hook side of native delivery: one bounded, fail-open attempt to bind
|
|
5
6
|
// this exact ACC session generation to the vendor session the adapter can
|
|
@@ -29,7 +30,7 @@ export function livePolicyFrom(env) {
|
|
|
29
30
|
const isPid = value => Number.isInteger(value) && value > 0;
|
|
30
31
|
|
|
31
32
|
export async function establishNativeBinding({ adapter, event, hookBinding, clientVersion,
|
|
32
|
-
platform, livePolicy, service, runtimeDir, clock,
|
|
33
|
+
platform, livePolicy, service, runtimeDir, clock, env,
|
|
33
34
|
timeoutMs = DEFAULT_TIMEOUT_MS, heartbeatCadenceMs = DEFAULT_CADENCE_MS }) {
|
|
34
35
|
const outcome = (state, reasonCode, modes = []) =>
|
|
35
36
|
Object.freeze({ state, reasonCode, modes: Object.freeze([...modes]) });
|
|
@@ -37,7 +38,7 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
|
|
|
37
38
|
const generation = hookBinding?.generation;
|
|
38
39
|
if (typeof sessionId !== "string" || typeof generation !== "string") return outcome("off", null);
|
|
39
40
|
const policy = LIVE_POLICIES.includes(livePolicy) ? livePolicy : "off";
|
|
40
|
-
const clear = () =>
|
|
41
|
+
const clear = () => retireNativeBinding({ adapter, service, sessionId, generation, runtimeDir, timeoutMs });
|
|
41
42
|
if (policy === "off") {
|
|
42
43
|
await clear();
|
|
43
44
|
return outcome("off", null);
|
|
@@ -52,12 +53,13 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
|
|
|
52
53
|
}
|
|
53
54
|
// Whatever this generation published before is retired first, so a failed
|
|
54
55
|
// re-handshake can never leave yesterday's endpoint reachable.
|
|
55
|
-
await clear();
|
|
56
|
+
if (!await clear()) return outcome("degraded", "handshake_failed");
|
|
56
57
|
let timer = null;
|
|
57
58
|
try {
|
|
58
59
|
const budget = Math.max(1, Math.floor(timeoutMs));
|
|
59
60
|
const handshake = await Promise.race([
|
|
60
|
-
adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
|
|
61
|
+
adapter.bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
|
|
62
|
+
timeoutMs: budget, env }),
|
|
61
63
|
new Promise((_resolve, reject) => {
|
|
62
64
|
timer = setTimeout(() => reject(Object.assign(new Error("native handshake timed out"),
|
|
63
65
|
{ code: "ETIMEDOUT" })), budget);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Retire the core fact first. Adapter cleanup is bounded and acts only on the
|
|
2
|
+
// endpoint whose retirement was observed; an expired registration is retained
|
|
3
|
+
// for on-demand verification, and a successor's endpoint is never touched.
|
|
4
|
+
async function attempt(run, deadlineAt) {
|
|
5
|
+
const timeoutMs = Math.max(0, deadlineAt - Date.now());
|
|
6
|
+
if (timeoutMs === 0) return null;
|
|
7
|
+
let timer;
|
|
8
|
+
try {
|
|
9
|
+
return await Promise.race([Promise.resolve().then(run), new Promise(resolve => {
|
|
10
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
11
|
+
})]);
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
finally { clearTimeout(timer); }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function retireNativeBinding({ adapter, service, sessionId, generation,
|
|
17
|
+
runtimeDir, timeoutMs = 200 }) {
|
|
18
|
+
const deadlineAt = Date.now() + Math.max(1, Math.floor(timeoutMs));
|
|
19
|
+
const cleanup = typeof adapter?.retireNativeSession === "function";
|
|
20
|
+
const read = () => service.store.ephemeral.get("deliveryBinding", sessionId);
|
|
21
|
+
const clear = opaqueEndpointRef => opaqueEndpointRef === undefined
|
|
22
|
+
? service.clearDeliveryBinding({ sessionId, generation, deadlineAt })
|
|
23
|
+
: service.clearDeliveryBinding({ sessionId, generation, opaqueEndpointRef, deadlineAt });
|
|
24
|
+
if (!cleanup) {
|
|
25
|
+
return await attempt(async () => {
|
|
26
|
+
await clear(undefined);
|
|
27
|
+
return true;
|
|
28
|
+
}, deadlineAt) === true;
|
|
29
|
+
}
|
|
30
|
+
// Observation is metadata, not the state transition. A hung read leaves half
|
|
31
|
+
// the shared retirement window for the exact core clear; a cheap read spends
|
|
32
|
+
// only the time it actually takes.
|
|
33
|
+
const observationDeadline = Date.now() + Math.max(1,
|
|
34
|
+
Math.floor(Math.max(0, deadlineAt - Date.now()) / 2));
|
|
35
|
+
const observed = await attempt(async () => ({ binding: await read() }), observationDeadline);
|
|
36
|
+
const prior = observed?.binding;
|
|
37
|
+
const cleared = await attempt(async () => {
|
|
38
|
+
await clear(prior?.opaqueEndpointRef ?? null);
|
|
39
|
+
return true;
|
|
40
|
+
}, deadlineAt) === true;
|
|
41
|
+
if (!cleared || observed === null) return false;
|
|
42
|
+
if (prior?.generation !== generation) return true;
|
|
43
|
+
const after = await attempt(read, deadlineAt);
|
|
44
|
+
if (after?.generation !== generation || after.retiredAt == null
|
|
45
|
+
|| after.opaqueEndpointRef !== prior.opaqueEndpointRef) return true;
|
|
46
|
+
await attempt(() => adapter.retireNativeSession({ binding: prior, runtimeDir }), deadlineAt);
|
|
47
|
+
return true;
|
|
48
|
+
}
|