Skip to content

Commit 658dd84

Browse files
neo-gpttobiu
andauthored
feat(memory-core): add harness presence address dispatch (#12422) (#12554)
Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
1 parent 2b8277c commit 658dd84

9 files changed

Lines changed: 994 additions & 55 deletions

File tree

ai/daemons/bridge/daemon.mjs

Lines changed: 128 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ import {
4646
getGraphLogEntries,
4747
getNodesData,
4848
getEdgesData,
49-
getDbNode
49+
getDbNode,
50+
getActiveHarnessPresence,
51+
isHarnessPresenceFresh
5052
} from './queries.mjs';
5153
import {applyHarnessMetadataDefaults} from '../../scripts/lifecycle/harnessRouting.mjs';
5254
import {getDefaultInstancePid, getInstancePid} from './instanceResolver.mjs';
@@ -676,6 +678,7 @@ let deliveryPromise = Promise.resolve();
676678
*/
677679
async function deliverDigest(subscription, digest) {
678680
const meta = subscription.properties?.harnessTargetMetadata || {};
681+
const {instanceAddress, addressType} = resolveInstanceAddress(meta);
679682
// Fall back to osascript on macOS by default, tmux otherwise
680683
const defaultAdapter = process.platform === 'darwin' ? 'osascript' : 'tmux';
681684
const adapter = meta.adapter || defaultAdapter;
@@ -684,8 +687,19 @@ async function deliverDigest(subscription, digest) {
684687
deliveryPromise = deliveryPromise.then(async () => {
685688

686689
try {
690+
if (addressType && instanceAddress && !assertFreshTargetPresence(subscription, {addressType})) {
691+
return;
692+
}
693+
694+
if (addressType === 'webhookUrl') {
695+
await deliverViaWebhookUrl(subscription, digest, instanceAddress);
696+
return;
697+
}
698+
687699
if (adapter === 'tmux') {
688-
const tmuxSession = meta.tmuxSession || process.env.TMUX_SESSION || 'neo-agent';
700+
const tmuxSession = addressType === 'tmuxSession' && instanceAddress
701+
? instanceAddress
702+
: meta.tmuxSession || process.env.TMUX_SESSION || 'neo-agent';
689703
await spawnAsync('tmux', ['send-keys', '-t', tmuxSession, digest, 'C-m']);
690704
writeLog('INFO', `[Bridge Daemon] Delivered ${subscription.id} via tmux to session ${tmuxSession}`);
691705
} else if (adapter === 'osascript') {
@@ -722,11 +736,10 @@ async function deliverDigest(subscription, digest) {
722736
return;
723737
}
724738

725-
// Instance-addressable wake — LOCAL deployment only. When the subscription carries a
726-
// userDataDir, two same-bundle GUI harnesses may be running; resolve the intended
727-
// instance's pid and raise THAT process — never the ambiguous app-activate / frontmost
728-
// guess. Fail closed (skip the wake) if the instance cannot be located, so a targeted
729-
// wake never lands in the wrong one.
739+
// Instance-addressable wake — LOCAL deployment only. When the subscription carries an
740+
// addressType, route through that address instead of falling back to ambiguous
741+
// app-activate/frontmost guessing. Fail closed (skip the wake) if the instance cannot
742+
// be located, so a targeted wake never lands in the wrong one.
730743
//
731744
// Instance addressing is a local-only primitive: this daemon delivers desktop-harness
732745
// wakes via osascript/tmux, which a headless cloud deployment has no GUI harness to
@@ -736,7 +749,24 @@ async function deliverDigest(subscription, digest) {
736749
// app-activate — a targeted wake must never silently degrade to an untargeted one. Uses
737750
// the canonical AiConfig.orchestrator.deploymentMode signal.
738751
let instancePid = null;
739-
if (meta.userDataDir) {
752+
if (addressType === 'pid') {
753+
if (AiConfig.orchestrator.deploymentMode === 'cloud') {
754+
writeLog('ERROR',
755+
`[Bridge Daemon] Instance wake refused for ${subscription.id}: ` +
756+
`pid targeting requires local deployment (deploymentMode='cloud'). ` +
757+
`Failing closed — instance-addressed GUI wakes are local-only (ADR 0014).`
758+
);
759+
return;
760+
}
761+
instancePid = normalizePid(instanceAddress);
762+
if (!instancePid) {
763+
writeLog('ERROR',
764+
`[Bridge Daemon] Instance wake refused for ${subscription.id}: ` +
765+
`invalid pid instanceAddress='${instanceAddress}'. Failing closed.`
766+
);
767+
return;
768+
}
769+
} else if (addressType === 'userDataDir' && instanceAddress) {
740770
if (AiConfig.orchestrator.deploymentMode === 'cloud') {
741771
writeLog('ERROR',
742772
`[Bridge Daemon] Instance wake refused for ${subscription.id}: ` +
@@ -745,11 +775,11 @@ async function deliverDigest(subscription, digest) {
745775
);
746776
return;
747777
}
748-
instancePid = await getInstancePid({userDataDir: meta.userDataDir});
778+
instancePid = await getInstancePid({userDataDir: instanceAddress});
749779
if (!instancePid) {
750780
writeLog('ERROR',
751781
`[Bridge Daemon] Instance wake refused for ${subscription.id}: ` +
752-
`no running process found for userDataDir='${meta.userDataDir}'. ` +
782+
`no running process found for userDataDir='${instanceAddress}'. ` +
753783
`Failing closed to avoid misrouting to another ${appName} instance.`
754784
);
755785
return;
@@ -945,6 +975,94 @@ async function deliverDigest(subscription, digest) {
945975
return deliveryPromise;
946976
}
947977

978+
/**
979+
* @summary Resolves generic instance-address metadata with legacy field compatibility.
980+
* @param {Object} meta Subscription harnessTargetMetadata.
981+
* @returns {{instanceAddress:String|null,addressType:String|null}}
982+
*/
983+
function resolveInstanceAddress(meta = {}) {
984+
const addressType = meta.addressType
985+
|| (meta.userDataDir ? 'userDataDir' : null);
986+
987+
const instanceAddress = meta.instanceAddress
988+
|| (addressType === 'userDataDir' ? meta.userDataDir : null);
989+
990+
return {
991+
instanceAddress: instanceAddress || null,
992+
addressType : addressType || null
993+
};
994+
}
995+
996+
/**
997+
* @summary Requires fresh HarnessPresence before immediate address-specific dispatch.
998+
* @param {Object} subscription WAKE_SUBSCRIPTION node.
999+
* @param {Object} address Resolved address tuple.
1000+
* @returns {Boolean}
1001+
*/
1002+
function assertFreshTargetPresence(subscription, {addressType}) {
1003+
const presence = getActiveHarnessPresence(db, {
1004+
subscriptionId: subscription.id,
1005+
agentIdentity : subscription.properties?.agentIdentity
1006+
});
1007+
1008+
if (isHarnessPresenceFresh(presence)) return true;
1009+
1010+
writeLog('WARN',
1011+
`[Bridge Daemon] Targeted wake refused for ${subscription.id}: ` +
1012+
`addressType='${addressType}' requires fresh HarnessPresence. ` +
1013+
`Failing closed; recipient will pick up the unread event on next turn.`
1014+
);
1015+
return false;
1016+
}
1017+
1018+
/**
1019+
* @summary Normalizes a pid string for direct-pid address dispatch.
1020+
* @param {*} pid Process id candidate.
1021+
* @returns {Number|null}
1022+
*/
1023+
function normalizePid(pid) {
1024+
const numericPid = Number(pid);
1025+
1026+
return Number.isInteger(numericPid) && numericPid > 0 ? numericPid : null;
1027+
}
1028+
1029+
/**
1030+
* @summary Posts a wake digest to a bridge-dispatchable webhook address.
1031+
* @param {Object} subscription WAKE_SUBSCRIPTION node.
1032+
* @param {String} digest Wake digest body.
1033+
* @param {String} webhookUrl Target webhook URL.
1034+
* @returns {Promise<void>}
1035+
*/
1036+
async function deliverViaWebhookUrl(subscription, digest, webhookUrl) {
1037+
let url;
1038+
try {
1039+
url = new URL(webhookUrl);
1040+
} catch (error) {
1041+
writeLog('ERROR',
1042+
`[Bridge Daemon] Webhook wake refused for ${subscription.id}: ` +
1043+
`invalid webhookUrl instanceAddress. Failing closed.`
1044+
);
1045+
return;
1046+
}
1047+
1048+
const response = await fetch(url, {
1049+
method : 'POST',
1050+
headers: {
1051+
'content-type': 'application/json'
1052+
},
1053+
body: JSON.stringify({
1054+
subscriptionId: subscription.id,
1055+
digest
1056+
})
1057+
});
1058+
1059+
if (!response.ok) {
1060+
throw new Error(`webhookUrl POST failed with HTTP ${response.status}`);
1061+
}
1062+
1063+
writeLog('INFO', `[Bridge Daemon] Delivered ${subscription.id} via webhookUrl POST`);
1064+
}
1065+
9481066
// Start loop
9491067
async function main() {
9501068
await enforceSingleton();

ai/daemons/bridge/queries.mjs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,85 @@ export function getActiveShapeCSubscriptions(db) {
3838
return collapseDuplicateShapeCRoutes(stmt.all().map(row => JSON.parse(row.data)));
3939
}
4040

41+
export const HARNESS_PRESENCE_STALE_AFTER_MS = 5 * 60 * 1000;
42+
43+
/**
44+
* @summary Loads the newest active HarnessPresence row for a bridge-daemon subscription.
45+
*
46+
* Presence is a volatile overlay keyed by subscription and identity. The bridge daemon consumes it
47+
* only as a freshness guard for targeted delivery; missing or stale presence must fail closed for
48+
* address-specific dispatch instead of falling through to the legacy untargeted activate path.
49+
*
50+
* @param {Database} db SQLite database handle.
51+
* @param {Object} opts
52+
* @param {String} opts.subscriptionId WAKE_SUBSCRIPTION id.
53+
* @param {String} opts.agentIdentity AgentIdentity node id.
54+
* @returns {Object|null} Parsed HARNESS_PRESENCE node.
55+
*/
56+
export function getActiveHarnessPresence(db, {subscriptionId, agentIdentity} = {}) {
57+
if (!subscriptionId && !agentIdentity) return null;
58+
59+
if (subscriptionId) {
60+
return getNewestHarnessPresence(db, {
61+
where : "json_extract(data, '$.properties.subscriptionId') = ?",
62+
params: [subscriptionId]
63+
});
64+
}
65+
66+
if (!agentIdentity) return null;
67+
68+
return getNewestHarnessPresence(db, {
69+
where : "json_extract(data, '$.properties.agentIdentity') = ?",
70+
params: [agentIdentity]
71+
});
72+
}
73+
74+
function getNewestHarnessPresence(db, {where, params}) {
75+
const rows = db.prepare(`
76+
SELECT data FROM Nodes
77+
WHERE json_extract(data, '$.label') = 'HARNESS_PRESENCE'
78+
AND COALESCE(json_extract(data, '$.properties.status'), 'active') = 'active'
79+
AND ${where}
80+
ORDER BY COALESCE(
81+
json_extract(data, '$.properties.lastSeenAt'),
82+
json_extract(data, '$.properties.updatedAt'),
83+
''
84+
) DESC
85+
LIMIT 1
86+
`).all(...params);
87+
88+
for (const row of rows) {
89+
try {
90+
return JSON.parse(row.data);
91+
} catch (error) {
92+
return null;
93+
}
94+
}
95+
96+
return null;
97+
}
98+
99+
/**
100+
* @summary Checks whether a HarnessPresence row is fresh enough for immediate targeted delivery.
101+
* @param {Object|null} presence Parsed HARNESS_PRESENCE node.
102+
* @param {Object} [opts]
103+
* @param {Number} [opts.now=Date.now()] Current timestamp in ms.
104+
* @param {Number} [opts.staleAfterMs=HARNESS_PRESENCE_STALE_AFTER_MS] Staleness threshold.
105+
* @returns {Boolean}
106+
*/
107+
export function isHarnessPresenceFresh(presence, {
108+
now = Date.now(),
109+
staleAfterMs = HARNESS_PRESENCE_STALE_AFTER_MS
110+
} = {}) {
111+
const props = presence?.properties || {};
112+
if ((props.status || 'active') !== 'active') return false;
113+
114+
const lastSeenAt = props.lastSeenAt ? new Date(props.lastSeenAt).getTime() : NaN;
115+
if (!Number.isFinite(lastSeenAt)) return false;
116+
117+
return now - lastSeenAt <= staleAfterMs;
118+
}
119+
41120
/**
42121
* @summary Collapses duplicate active Shape C wake routes before bridge-daemon dispatch.
43122
*

ai/mcp/server/memory-core/openapi.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1351,14 +1351,16 @@ paths:
13511351
description: Required for subscribe; specifies how wake events are delivered.
13521352
harnessTargetMetadata:
13531353
type: object
1354-
description: Channel-specific metadata. appName is required so fresh agents always provide the bridge-daemon/osascript routing target; url is required for a2a-webhook; coalesceWindow is an optional override. userDataDir (local bridge-daemon only) addresses one specific same-bundle GUI instance when two harnesses of the same app run in parallel — the daemon resolves that instance's pid from its --user-data-dir and raises only that process.
1354+
description: Channel-specific metadata. appName is required so fresh agents always provide the bridge-daemon/osascript routing target; url is required for a2a-webhook; coalesceWindow is an optional override. instanceAddress + addressType (local bridge-daemon only) target a specific receiver instance; userDataDir is a legacy compatibility field for existing subscriptions.
13551355
required: [appName]
13561356
properties:
13571357
url: {type: string}
13581358
coalesceWindow: {type: integer, minimum: 0, maximum: 300}
13591359
daemonSocketPath: {type: string}
13601360
adapter: {type: string, enum: [osascript, tmux]}
13611361
appName: {type: string}
1362+
instanceAddress: {type: string, nullable: true}
1363+
addressType: {type: string, enum: [userDataDir, pid, tmuxSession, webhookUrl], nullable: true}
13621364
userDataDir: {type: string, nullable: true}
13631365
tabShortcut: {type: string, nullable: true}
13641366
focusSeedKey: {type: string, nullable: true}

ai/mcp/server/shared/services/BootEnvelopeResolver.mjs

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,11 @@ import Base from '../../../../../src/core/Base.mjs';
3333
*
3434
* ## Dispatch coverage
3535
*
36-
* The bridge daemon currently dispatches the `userDataDir` address kind. The `pid`, `tmuxSession`,
37-
* and `webhookUrl` kinds are recognized envelope values reserved for the forthcoming generic
38-
* address-type dispatch; until that lands they fail closed here rather than produce a subscription
39-
* the daemon would misroute. For `userDataDir`, the address is also mirrored onto the legacy
40-
* `userDataDir` metadata field the daemon reads today; that mirror is a transitional bridge and is
41-
* retired once dispatch reads the generic `addressType` directly.
36+
* The bridge daemon dispatches the generic `{instanceAddress, addressType}` pair directly:
37+
* `userDataDir` resolves to a same-bundle GUI process, `pid` targets that process directly,
38+
* `tmuxSession` sends to tmux, and `webhookUrl` posts the wake digest. The earlier transitional
39+
* `userDataDir` mirror is retired here; legacy subscriptions that already carry that field remain
40+
* a bridge-daemon compatibility read concern, not a boot-envelope output.
4241
*
4342
* Pure resolution is intentionally side-effect-free (reads `process.env` only) so the mapping is
4443
* unit-testable with injected environments.
@@ -65,16 +64,15 @@ class BootEnvelopeResolver extends Base {
6564

6665
/**
6766
* @member {String[]} validAddressTypes
68-
* The recognized instance-address kinds. `userDataDir` is dispatched today; the remainder are
69-
* reserved for generic address-type dispatch and currently fail closed.
67+
* The recognized instance-address kinds.
7068
*/
7169
validAddressTypes = ['userDataDir', 'pid', 'tmuxSession', 'webhookUrl']
7270

7371
/**
7472
* @member {String[]} dispatchableAddressTypes
7573
* The subset of {@link validAddressTypes} the bridge daemon can route today.
7674
*/
77-
dispatchableAddressTypes = ['userDataDir']
75+
dispatchableAddressTypes = ['userDataDir', 'pid', 'tmuxSession', 'webhookUrl']
7876

7977
/**
8078
* Resolves the boot instance-address envelope into wake-subscription `overrideMetadata`.
@@ -123,16 +121,7 @@ class BootEnvelopeResolver extends Base {
123121
);
124122
}
125123

126-
const overrideMetadata = {instanceAddress, addressType};
127-
128-
// Transitional daemon-compat: the live bridge daemon resolves the target instance from the
129-
// `userDataDir` metadata field. Mirror the address there so wake routing is functional now;
130-
// the mirror is retired once dispatch reads `addressType` directly.
131-
if (addressType === 'userDataDir') {
132-
overrideMetadata.userDataDir = instanceAddress;
133-
}
134-
135-
return overrideMetadata;
124+
return {instanceAddress, addressType};
136125
}
137126
}
138127

0 commit comments

Comments
 (0)