@twinsearth/w2m-dsh-plugin 0.4.9 → 0.5.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.
@@ -49,7 +49,8 @@ import {
49
49
  } from './git.mjs';
50
50
  import { createSpool } from './spool.mjs';
51
51
  import { matchAllowedCommand, parseAllowedCommands } from './allowed-commands.mjs';
52
- import { DEFAULT_P2P_MODE, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
52
+ import { DEFAULT_P2P_MODE, P2P_DEFAULTS, P2PNode, normalizeP2PMode, normalizeP2PPort } from './p2p-node.mjs';
53
+ import { punchSession, reverseDialSession } from './p2p.mjs';
53
54
  import { createSigner } from '../signing.mjs';
54
55
  import { joinUrl, resolveBaseUrl } from './url.mjs';
55
56
 
@@ -187,6 +188,42 @@ export const P2P_RESULT_ACK_TIMEOUT_MS = 2_000;
187
188
  * rather than describe a 30-second wait.
188
189
  */
189
190
  export const P2P_CHANNEL_LINGER_MS = 30_000;
191
+
192
+ /**
193
+ * Why this agent's own outbound dial did not open a channel (v0.4.x), with its own codes.
194
+ *
195
+ * These are reported in the result envelope's `p2p.reverse_dial_reason`, never thrown and never
196
+ * fatal: a reverse dial that fails changes nothing about the relay path, and "the punch timed out"
197
+ * and "there was nothing to punch at" are different facts that need different responses. A timeout
198
+ * is the ordinary outcome for a dispatcher behind a symmetric NAT that answered nothing -- the same
199
+ * code the dispatcher's own failed push reports (`P2P_PUNCH_TIMEOUT`), prefixed so a reader can tell
200
+ * which end dialled.
201
+ */
202
+ export const P2P_REVERSE_DIAL_TIMEOUT = 'P2P_REVERSE_DIAL_TIMEOUT';
203
+ export const P2P_REVERSE_DIAL_FAILED = 'P2P_REVERSE_DIAL_FAILED';
204
+
205
+ /**
206
+ * The plan code for "there is a live channel, so there is nothing to dial".
207
+ *
208
+ * Not a failure: it is the ordinary answer when the dispatcher's own push landed. It is a distinct
209
+ * code because the *result* path re-plans on it: a channel that existed when the offer arrived and is
210
+ * gone when the result is ready (the dispatcher closes the channel it pushed the offer over) gets the
211
+ * one reverse-dial attempt the contract allows, whereas every other skip reason stays skipped.
212
+ */
213
+ export const P2P_CHANNEL_LIVE = 'P2P_CHANNEL_LIVE';
214
+
215
+ /**
216
+ * The mechanism names that go into `p2p.opened_by`.
217
+ *
218
+ * Named rather than inferred at the reader's end: `result_path: 'p2p'` says the result travelled
219
+ * directly, and this says *what opened the path* -- the dispatcher's punch, this machine's dial at
220
+ * offer time (the simultaneous punch), or this machine's dial once the result was ready.
221
+ */
222
+ export const P2P_OPENED_BY = Object.freeze({
223
+ DISPATCHER_PUNCH: 'dispatcher-punch',
224
+ SIMULTANEOUS_PUNCH: 'simultaneous-punch',
225
+ RESULT_REVERSE_DIAL: 'result-reverse-dial',
226
+ });
190
227
  /**
191
228
  * Upper bound on the stages a single offer may carry (v0.3.3 `pipeline`).
192
229
  *
@@ -1003,6 +1040,50 @@ export function createAgent(options) {
1003
1040
  */
1004
1041
  const p2pChannels = new Map();
1005
1042
 
1043
+ /**
1044
+ * The dispatcher address a task's channel actually travels to, remembered past the channel.
1045
+ *
1046
+ * This is the *observed* address for a channel that arrived inbound (`accept()` answers to the
1047
+ * `rinfo` of the HELLO it received, never to the address that peer announced) and the punched
1048
+ * address for one this agent opened. It outlives the channel on purpose: when the dispatcher
1049
+ * closes the channel it pushed the offer over -- which it does, right after the offer -- the
1050
+ * result still has somewhere to go, and the observed address is the one address a symmetric NAT
1051
+ * on the dispatcher's side is still holding a mapping for. Retyping the announced candidate
1052
+ * instead is the punch that already failed.
1053
+ *
1054
+ * @type {Map<string, {address: string, port: number}>}
1055
+ */
1056
+ const p2pPeers = new Map();
1057
+
1058
+ /**
1059
+ * One reverse-dial attempt per `task_id` — the executor's own half of a two-way punch.
1060
+ *
1061
+ * A record exists for every offer this agent takes on, including the ones it decides *not* to
1062
+ * dial for, and that is deliberate: "we did not try, because a channel was already live" and "we
1063
+ * tried and it timed out" must be distinguishable at result time, and a missing record must not
1064
+ * silently mean a second attempt. `promise` settles when the dial is over; it is awaited by the
1065
+ * result path (bounded by the same tuning window), never by the offer path.
1066
+ *
1067
+ * @type {Map<string, {task_id: string, attempted: boolean, ok: boolean, code: string|null,
1068
+ * reason: string|null, stage: 'offer'|'result', session: number|null, candidates: number,
1069
+ * channel: object|null, promise: Promise<object>|null, ms: number|null}>}
1070
+ */
1071
+ const p2pReverseDials = new Map();
1072
+
1073
+ /**
1074
+ * How long this agent may spend dialling a dispatcher, from the node's own tuning.
1075
+ *
1076
+ * Read from the node rather than fixed here so the number lives beside `punchTimeoutMs` and
1077
+ * `acceptTimeoutMs`: an operator tuning a slow path tunes one object, and a test that cannot wait
1078
+ * eight seconds injects one.
1079
+ */
1080
+ function reverseDialTimeoutMs() {
1081
+ const configured = p2pNode?.tuning?.reverseDialTimeoutMs;
1082
+ return typeof configured === 'number' && Number.isFinite(configured) && configured > 0
1083
+ ? configured
1084
+ : P2P_DEFAULTS.reverseDialTimeoutMs;
1085
+ }
1086
+
1006
1087
  /** The agent's P2P facts, always a fresh plain object. */
1007
1088
  function p2pStatusSnapshot() {
1008
1089
  const nodeStatus = p2pNode?.status ?? null;
@@ -1193,6 +1274,17 @@ export function createAgent(options) {
1193
1274
 
1194
1275
  /** Remember which channel a task's result should travel back over, and release it on close. */
1195
1276
  function rememberChannel(taskId, channel) {
1277
+ const peer = channel?.peer;
1278
+ if (
1279
+ peer !== null &&
1280
+ typeof peer?.address === 'string' &&
1281
+ peer.address !== '' &&
1282
+ Number.isInteger(peer?.port) &&
1283
+ peer.port > 0
1284
+ ) {
1285
+ // Kept even though this channel may be replaced or closed below: see `p2pPeers`.
1286
+ p2pPeers.set(taskId, { address: peer.address, port: peer.port });
1287
+ }
1196
1288
  const previous = p2pChannels.get(taskId);
1197
1289
  if (previous?.channel === channel) return;
1198
1290
  if (previous) previous.channel.removeListener('close', previous.onClose);
@@ -1207,7 +1299,231 @@ export function createAgent(options) {
1207
1299
  const channelFor = (taskId) => p2pChannels.get(taskId)?.channel ?? null;
1208
1300
 
1209
1301
  /**
1210
- * Release the channel a task used, once the attempt is over.
1302
+ * What a reverse dial would need, or the named reason there is nothing to dial.
1303
+ *
1304
+ * WHY THE EXECUTOR DIALS AT ALL
1305
+ *
1306
+ * The dispatcher pushes first, and if it is behind a symmetric NAT (CGNAT, in practice) its HELLOs
1307
+ * are addressed from a mapped port that nobody was told about, so they reach nothing. Its own
1308
+ * outbound packet still opened a mapping *towards this machine*, though -- and that is half a path.
1309
+ * The other half is a packet from here, addressed at the dispatcher: the moment this machine sends
1310
+ * one, its own NAT lets the dispatcher's HELLO through and the second packet lands on a path that is
1311
+ * already open. Neither end can do it alone, which is why a peer that only ever accepted cannot
1312
+ * rescue a CGNAT initiator.
1313
+ *
1314
+ * WHERE THE ADDRESSES COME FROM
1315
+ *
1316
+ * The offer's own `p2p.candidates` when the dispatcher published them (it knows its own announced
1317
+ * addresses better than a relay lookup that may already have expired), with the address this task's
1318
+ * channel actually reached first when there was one, and the relay's `/v1/peer/{id}` answer as the
1319
+ * node's own fallback when neither is available.
1320
+ *
1321
+ * @param {object} offer
1322
+ * @returns {{ok:true, machineId:string, session:number, candidates:Array<object>, from:string}
1323
+ * |{ok:false, code:string, reason:string}}
1324
+ */
1325
+ function reverseDialPlan(offer) {
1326
+ const taskId = typeof offer?.task_id === 'string' && offer.task_id !== '' ? offer.task_id : null;
1327
+ if (taskId === null) {
1328
+ return { ok: false, code: 'P2P_NO_TASK_ID', reason: 'P2P_NO_TASK_ID: the offer names no task to dial for' };
1329
+ }
1330
+ if (p2pMode === 'relay') {
1331
+ return {
1332
+ ok: false,
1333
+ code: 'P2P_DISABLED',
1334
+ reason: 'P2P_DISABLED: p2pMode is "relay", so this machine never dials (the relay path is the only path)',
1335
+ };
1336
+ }
1337
+ if (p2pNode === null || p2pNode.status.running !== true) {
1338
+ return {
1339
+ ok: false,
1340
+ code: 'P2P_UNREACHABLE',
1341
+ reason: 'P2P_UNREACHABLE: the P2P node is not running, so no direct path can be dialled',
1342
+ };
1343
+ }
1344
+ if (channelFor(taskId) !== null) {
1345
+ return {
1346
+ ok: false,
1347
+ code: P2P_CHANNEL_LIVE,
1348
+ reason: `P2P_CHANNEL_LIVE: a direct channel for ${taskId} is already open, so there is nothing to dial`,
1349
+ };
1350
+ }
1351
+
1352
+ const block = offer?.p2p;
1353
+ if (block === null || typeof block !== 'object' || Array.isArray(block)) {
1354
+ return {
1355
+ ok: false,
1356
+ code: 'P2P_NO_SESSION',
1357
+ reason: 'P2P_NO_SESSION: the offer published no `p2p` block, so there is no session to join',
1358
+ };
1359
+ }
1360
+ const dispatcher =
1361
+ typeof offer?.origin_machine_id === 'string' && offer.origin_machine_id !== ''
1362
+ ? offer.origin_machine_id
1363
+ : null;
1364
+ if (dispatcher === null) {
1365
+ return {
1366
+ ok: false,
1367
+ code: 'P2P_NO_ORIGIN',
1368
+ reason: 'P2P_NO_ORIGIN: the offer does not name the machine that dispatched it, so there is nothing to dial',
1369
+ };
1370
+ }
1371
+
1372
+ const machineId = identity.machine_id;
1373
+ const published = Array.isArray(block.candidates) ? block.candidates : [];
1374
+ let session = Number.isInteger(block.session) && block.session > 0 ? block.session : null;
1375
+ let from = 'offer session';
1376
+ if (session === null) {
1377
+ session = punchSession({ punch: block.punch, taskId, machineId });
1378
+ from = 'offer punch';
1379
+ }
1380
+ if (session === null && published.length > 0) {
1381
+ // A dispatcher that published where to dial but not which session: this end picks one and the
1382
+ // far end's `accept()` adopts it -- that asymmetry is what `allowSessionAdoption` exists for.
1383
+ session = reverseDialSession({ originMachineId: dispatcher, taskId, machineId });
1384
+ from = 'derived session';
1385
+ }
1386
+ if (session === null) {
1387
+ // A v0.4.x dispatcher: it published no punch, so there is no session to join and no address it
1388
+ // asked to be reached at. Dialling anyway would be a punch this end invented against a machine
1389
+ // that never said a two-way punch was in play, so the offer is treated exactly as it was before
1390
+ // this change -- the relay carries the result, and the reason says why.
1391
+ return {
1392
+ ok: false,
1393
+ code: 'P2P_NO_SESSION',
1394
+ reason:
1395
+ 'P2P_NO_SESSION: the offer published no session or candidates, so there is nothing to dial ' +
1396
+ '(a v0.4.x dispatcher, whose relay copy is the delivery)',
1397
+ };
1398
+ }
1399
+
1400
+ const observed = p2pPeers.get(taskId) ?? null;
1401
+ return {
1402
+ ok: true,
1403
+ machineId: dispatcher,
1404
+ session,
1405
+ candidates: observed === null ? [...published] : [observed, ...published],
1406
+ from: observed === null ? from : `${from} + observed peer`,
1407
+ };
1408
+ }
1409
+
1410
+ /**
1411
+ * Start (or reuse) this task's one reverse-dial attempt. Never awaited by the offer path.
1412
+ *
1413
+ * @param {object} offer
1414
+ * @param {{stage?: 'offer'|'result', afterLiveChannel?: boolean}} [options]
1415
+ * @returns {object|null} The record; `promise` settles when the dial is over.
1416
+ */
1417
+ function startReverseDial(offer, options = {}) {
1418
+ const taskId = typeof offer?.task_id === 'string' && offer.task_id !== '' ? offer.task_id : null;
1419
+ if (taskId === null) return null;
1420
+ const stage = options.stage === 'result' ? 'result' : 'offer';
1421
+
1422
+ const existing = p2pReverseDials.get(taskId) ?? null;
1423
+ if (existing !== null) {
1424
+ // One attempt per task, with exactly one exception: a decision that said "a channel is already
1425
+ // live" is re-planned at result time, because that channel may be gone by then and the result
1426
+ // is what the dial exists for. Everything else -- attempted, disabled, unreachable, no session
1427
+ // -- is final, so a result never pays for a second eight-second wait.
1428
+ const rePlannable = existing.attempted !== true && existing.code === P2P_CHANNEL_LIVE;
1429
+ if (!(options.afterLiveChannel === true && rePlannable)) return existing;
1430
+ }
1431
+
1432
+ const plan = reverseDialPlan(offer);
1433
+ /** @type {object} */
1434
+ const record = {
1435
+ task_id: taskId,
1436
+ attempted: plan.ok === true,
1437
+ ok: false,
1438
+ code: plan.ok === true ? null : plan.code,
1439
+ reason: plan.ok === true ? null : plan.reason,
1440
+ stage,
1441
+ session: plan.ok === true ? plan.session : null,
1442
+ candidates: plan.ok === true ? plan.candidates.length : 0,
1443
+ from: plan.ok === true ? plan.from : null,
1444
+ channel: null,
1445
+ promise: null,
1446
+ ms: null,
1447
+ };
1448
+ p2pReverseDials.set(taskId, record);
1449
+
1450
+ if (!plan.ok) {
1451
+ record.promise = Promise.resolve(record);
1452
+ if (plan.code !== P2P_CHANNEL_LIVE) {
1453
+ log('info', `not dialling back for ${taskId}: ${plan.reason}`);
1454
+ }
1455
+ return record;
1456
+ }
1457
+
1458
+ const timeoutMs = reverseDialTimeoutMs();
1459
+ const startedAt = process.hrtime.bigint();
1460
+ record.promise = (async () => {
1461
+ let dialled;
1462
+ try {
1463
+ dialled = await p2pNode.dial(plan.machineId, {
1464
+ session: plan.session,
1465
+ candidates: plan.candidates,
1466
+ timeoutMs,
1467
+ });
1468
+ } catch (error) {
1469
+ // `dial()` documents that it never throws; caught anyway, because this runs detached from
1470
+ // every caller and an unhandled rejection is the one failure that could take the process down.
1471
+ dialled = { ok: false, error: `P2P_REVERSE_DIAL_FAILED: ${error?.message ?? String(error)}` };
1472
+ }
1473
+ record.ms = Math.round(Number(process.hrtime.bigint() - startedAt) / 1e5) / 10;
1474
+ if (dialled?.ok === true) {
1475
+ record.ok = true;
1476
+ record.channel = dialled.channel;
1477
+ record.reused = dialled.reused === true;
1478
+ rememberChannel(taskId, dialled.channel);
1479
+ log('info', `reverse dial to ${plan.machineId} opened a channel for ${taskId}`, {
1480
+ session: dialled.session,
1481
+ ms: record.ms,
1482
+ reused: record.reused,
1483
+ candidates: plan.candidates.length,
1484
+ });
1485
+ return record;
1486
+ }
1487
+ const text = typeof dialled?.error === 'string' && dialled.error !== '' ? dialled.error : 'the dial reported no reason';
1488
+ record.reason = text.startsWith('P2P_PUNCH_TIMEOUT')
1489
+ ? `${P2P_REVERSE_DIAL_TIMEOUT}: ${text}`
1490
+ : `${P2P_REVERSE_DIAL_FAILED}: ${text}`;
1491
+ // A distinct code from the reason: the full message names the candidates and the window, and
1492
+ // this is what a reader or a metric can match on without parsing prose.
1493
+ record.code = record.reason.slice(0, record.reason.indexOf(':'));
1494
+ log('warn', `reverse dial for ${taskId} did not open a channel; the relay path is unaffected`, {
1495
+ reason: record.reason,
1496
+ ms: record.ms,
1497
+ });
1498
+ return record;
1499
+ })();
1500
+
1501
+ return record;
1502
+ }
1503
+
1504
+ /**
1505
+ * The reverse-dial facts one result envelope reports, or null when there was no record.
1506
+ *
1507
+ * @param {string} taskId
1508
+ * @returns {object|null}
1509
+ */
1510
+ function reverseDialSummary(taskId) {
1511
+ const record = p2pReverseDials.get(taskId) ?? null;
1512
+ if (record === null) return null;
1513
+ return {
1514
+ attempted: record.attempted === true,
1515
+ ok: record.ok === true,
1516
+ code: record.code,
1517
+ reason: record.reason,
1518
+ stage: record.stage,
1519
+ session: record.session,
1520
+ candidates: record.candidates,
1521
+ ms: record.ms,
1522
+ channel: record.channel ?? null,
1523
+ };
1524
+ }
1525
+
1526
+ /** Release the channel a task used, once the attempt is over.
1211
1527
  *
1212
1528
  * A channel exists for one attempt: the offer arrives on it and the result notice is acknowledged
1213
1529
  * on it, and nothing else is expected. Without this, an agent accumulated one live session per task
@@ -1217,6 +1533,8 @@ export function createAgent(options) {
1217
1533
  * channel to answer on (the relay copy is the durable one).
1218
1534
  */
1219
1535
  function releaseChannel(taskId) {
1536
+ p2pReverseDials.delete(taskId);
1537
+ p2pPeers.delete(taskId);
1220
1538
  const entry = p2pChannels.get(taskId);
1221
1539
  if (!entry) return;
1222
1540
  p2pChannels.delete(taskId);
@@ -1237,14 +1555,30 @@ export function createAgent(options) {
1237
1555
  * is answered over the channel, and that is only possible while the channel exists. 30 seconds is
1238
1556
  * long enough to cover a retry and short enough that a long-lived agent does not accumulate one
1239
1557
  * session per task it has ever run.
1558
+ *
1559
+ * Scheduled even when there is no channel: the timer is also what releases this task's remembered
1560
+ * peer address, and a task whose dial timed out has exactly that and no channel.
1240
1561
  */
1241
1562
  function scheduleChannelRelease(taskId) {
1242
1563
  const entry = p2pChannels.get(taskId);
1243
- if (!entry) return;
1244
- clearTimeout(entry.lingerTimer);
1564
+ if (entry) clearTimeout(entry.lingerTimer);
1245
1565
  const timer = setTimeout(() => releaseChannel(taskId), p2pChannelLingerMs);
1246
1566
  timer.unref?.();
1247
- entry.lingerTimer = timer;
1567
+ if (entry) entry.lingerTimer = timer;
1568
+ }
1569
+
1570
+ /**
1571
+ * Let go of one task's dial record, once its envelope has been built.
1572
+ *
1573
+ * That envelope is the record's last reader: the record exists to decide `result_path` and to name
1574
+ * the mechanism that opened the channel, and both are inside the envelope by the time this runs. A
1575
+ * re-attempt of the same task (`attempt + 1`) is a new decision and starts a fresh dial, which is
1576
+ * why this is not left to the linger timer -- that one waits 30 s, and a retry can arrive sooner.
1577
+ *
1578
+ * @param {string} taskId
1579
+ */
1580
+ function releaseDialRecord(taskId) {
1581
+ p2pReverseDials.delete(taskId);
1248
1582
  }
1249
1583
 
1250
1584
  /** The STUN list the node is actually querying, for a status surface that can be quoted. */
@@ -1815,6 +2149,28 @@ export function createAgent(options) {
1815
2149
  * * `session` -- the punch's session id, which is what correlates two agents' logs.
1816
2150
  * * `mapping` -- the NAT mapping this agent's announcement measured.
1817
2151
  *
2152
+ * v0.4.x adds three more, because "the result came back over the relay" and "the result came back
2153
+ * over the relay *after this machine spent eight seconds dialling*" are different operational
2154
+ * facts and the v0.4.0 block could not tell them apart:
2155
+ *
2156
+ * * `opened_by` -- which mechanism opened the channel, named rather than inferred:
2157
+ * `dispatcher-punch` (the dispatcher's outbound HELLO landed), `simultaneous-punch`
2158
+ * (this machine dialled as the offer arrived, and that is what opened both
2159
+ * filters), `result-reverse-dial` (this machine dialled once the result was
2160
+ * ready). Absent when no channel carried anything.
2161
+ * * `reverse_dial` -- `'p2p'` when this machine's own dial opened a channel, `'relay'` when it was
2162
+ * attempted and did not. Absent when it was never attempted, which is not a
2163
+ * failure and must not be spelled as one.
2164
+ * * `reverse_dial_reason` -- the named code when the dial did not open a channel
2165
+ * (`P2P_REVERSE_DIAL_TIMEOUT`, `P2P_REVERSE_DIAL_FAILED`), or null.
2166
+ * * `reverse_dial_ms` -- what the dial cost, in milliseconds. The window is a bound the contract
2167
+ * states, and a bound nobody measures is a claim: this is the number that says
2168
+ * whether the result path really stayed inside it.
2169
+ * * `result_error` -- why the direct copy of *this result* did not land, when one was attempted
2170
+ * and not acknowledged (`P2P_ACK_TIMEOUT: …`). Null otherwise; this is the
2171
+ * `delivery.error` that `p2pDeliveryMark` has always carried and the envelope
2172
+ * never reported.
2173
+ *
1818
2174
  * @param {object} context
1819
2175
  * @returns {object}
1820
2176
  */
@@ -1823,11 +2179,42 @@ export function createAgent(options) {
1823
2179
  const transport = context.p2pTransport ?? offerTransportOf(offer, p2pStatusSnapshot());
1824
2180
  const delivery = context.p2pDelivery ?? p2pDeliveryMark();
1825
2181
  const record = context.p2pRecord ?? null;
2182
+ const reverse = context.p2pReverse ?? null;
1826
2183
  const dispatcherMode = offer?.p2p?.mode;
2184
+
2185
+ /**
2186
+ * Which mechanism opened the channel that carried the offer or the result.
2187
+ *
2188
+ * Identity, not timing: the record keeps the channel object its dial opened, so "the reverse dial
2189
+ * is what carried this result" is a comparison of two references rather than a guess from the
2190
+ * order things happened in. A channel that arrived on its own can only be the dispatcher's punch.
2191
+ */
2192
+ const usedChannel = channelFor(offer?.task_id);
2193
+ let openedBy = null;
2194
+ if (delivery.path === 'p2p') {
2195
+ if (reverse?.ok === true && reverse.channel !== null && reverse.channel === usedChannel) {
2196
+ openedBy = reverse.stage === 'result'
2197
+ ? P2P_OPENED_BY.RESULT_REVERSE_DIAL
2198
+ : P2P_OPENED_BY.SIMULTANEOUS_PUNCH;
2199
+ } else {
2200
+ openedBy = P2P_OPENED_BY.DISPATCHER_PUNCH;
2201
+ }
2202
+ } else if (transport.offer_path === 'p2p') {
2203
+ openedBy = P2P_OPENED_BY.DISPATCHER_PUNCH;
2204
+ }
2205
+
1827
2206
  return {
1828
2207
  mode: typeof dispatcherMode === 'string' && dispatcherMode !== '' ? dispatcherMode : p2pMode,
1829
2208
  offer_path: transport.offer_path,
1830
2209
  ...(delivery.path === null ? {} : { result_path: delivery.path }),
2210
+ ...(openedBy === null ? {} : { opened_by: openedBy }),
2211
+ ...(reverse === null || reverse.attempted !== true
2212
+ ? {}
2213
+ : { reverse_dial: reverse.ok === true ? 'p2p' : 'relay' }),
2214
+ ...(reverse !== null && typeof reverse.reason === 'string' && reverse.reason !== ''
2215
+ ? { reverse_dial_reason: reverse.reason }
2216
+ : {}),
2217
+ ...(typeof reverse?.ms === 'number' ? { reverse_dial_ms: reverse.ms } : {}),
1831
2218
  // The key is OMITTED when there is nothing to explain, and that is not the same as setting it
1832
2219
  // to `undefined`: `JSON.stringify` would drop that, but the envelope hash is computed with JCS,
1833
2220
  // whose implementation (rightly) refuses a non-JSON value. Measured -- `reason: undefined`
@@ -1835,9 +2222,17 @@ export function createAgent(options) {
1835
2222
  // `jcs: property reason is undefined`, so the task executed and then vanished from the ledger.
1836
2223
  ...(typeof transport.reason === 'string' && transport.reason !== '' ? { reason: transport.reason } : {}),
1837
2224
  rtt_ms: delivery.rtt_ms ?? record?.rtt_ms ?? null,
1838
- peer: record?.peer ?? null,
1839
- session: record?.session ?? null,
2225
+ // The peer address and session of whichever channel this machine holds for the task: the one
2226
+ // that carried the offer, or the one its own dial opened. For an inbound channel `peer` stays
2227
+ // null (a HELLO carries a session, not an identity) -- but a channel *this* machine dialled has
2228
+ // a peer address it chose, and reporting "no peer" there would hide the one address the punch
2229
+ // actually used.
2230
+ peer: record?.peer ?? (reverse?.ok === true && reverse.channel !== null ? { ...reverse.channel.peer } : null),
2231
+ session: record?.session ?? reverse?.session ?? null,
1840
2232
  mapping: p2pNode?.status.mapping ?? null,
2233
+ ...(delivery.asked === true && delivery.path === null && typeof delivery.error === 'string' && delivery.error !== ''
2234
+ ? { result_error: delivery.error }
2235
+ : {}),
1841
2236
  };
1842
2237
  }
1843
2238
 
@@ -1957,11 +2352,19 @@ export function createAgent(options) {
1957
2352
  // v0.4.0 §6. Always present, like the relay's own offer fields and for the same reason: a
1958
2353
  // v0.3.9 machine and an `auto` one are then distinguishable from the stored envelope alone,
1959
2354
  // instead of `transport` being absent both when the direct path was never tried and when it
1960
- // was tried and lost. `transport` is how the *offer* arrived; `p2p.result_path` is how the
1961
- // *result* left. They are different facts and are allowed to differ -- a relay offer can be
1962
- // answered over a channel the agent opened for that task.
2355
+ // was tried and lost.
2356
+ //
2357
+ // v0.4.x widens what `transport` answers, without moving either of the two facts it is read for.
2358
+ // It is "did the direct path carry anything for this machine" -- `p2p` when the offer arrived over
2359
+ // a channel *or* when the result was acknowledged over one, `relay` when the relay carried both.
2360
+ // `p2p.offer_path` and `p2p.result_path` remain the halves, so a reader that needs "which way did
2361
+ // the offer come" still has it verbatim; what changed is that a machine whose *result* found its
2362
+ // own way back no longer looks like a machine that fell all the way to the relay. That is the
2363
+ // reading the relay's own report already documents ("how this machine's envelope travelled"), and
2364
+ // the one a deployment-level question like "is P2P working here" needs answered.
1963
2365
  const transport = context.p2pTransport ?? offerTransportOf(offer, p2pStatusSnapshot());
1964
- envelope.transport = transport.offer_path;
2366
+ const directResult = (context.p2pDelivery ?? p2pDeliveryMark()).path === 'p2p';
2367
+ envelope.transport = transport.offer_path === 'p2p' || directResult ? 'p2p' : transport.offer_path;
1965
2368
  envelope.p2p = envelopeP2P({ ...context, p2pTransport: transport });
1966
2369
  return buildEnvelope(envelope);
1967
2370
  }
@@ -2089,6 +2492,15 @@ export function createAgent(options) {
2089
2492
  write: offer.write === true,
2090
2493
  });
2091
2494
 
2495
+ // ---- 1b. the two-way punch (v0.4.x) ----------------------------------
2496
+ // Started here, and *started* rather than awaited: the dispatcher may be behind a symmetric NAT
2497
+ // that swallowed its push, and a packet from this machine is the only thing that can open the
2498
+ // other half of that path. Everything the relay does today happens exactly as it did -- the
2499
+ // lease heartbeat, the gates, the command, the relay copy of the result -- while this runs
2500
+ // beside it for a bounded window. Nothing below waits for it; the result path is the one place
2501
+ // that reads its outcome, and even there only to decide `result_path`.
2502
+ startReverseDial(offer);
2503
+
2092
2504
  // ---- 2. lease heartbeat ---------------------------------------------
2093
2505
  heartbeat = new Heartbeat({
2094
2506
  send: (body) => postJson('/v1/heartbeat', body).then((response) => response.json),
@@ -2425,10 +2837,39 @@ export function createAgent(options) {
2425
2837
  * envelope on the channel would need the envelope before the acknowledgement that decides one of
2426
2838
  * its fields -- see the note in the v0.4.0 report and `PROTOCOL-v0.4.0.md`'s open question.
2427
2839
  *
2840
+ * A channel is no longer a precondition (v0.4.x): when there is none, this dials the dispatcher
2841
+ * first (see `reverseDialPlan`) and uses the channel that opens. What has *not* changed is the
2842
+ * bound and the order -- the relay copy still goes out after this returns, whatever it decided, and
2843
+ * the whole function still costs at most one dial window plus one ack window.
2844
+ *
2428
2845
  * @param {object} offer
2846
+ * @param {string|null} [status] The outcome this envelope is about to report. A `refused` attempt
2847
+ * has no direct result worth dialling for: nothing ran, the envelope that says so travels over
2848
+ * the relay exactly as it does today, and paying the dial window for it would delay a refusal
2849
+ * for no gain. The value matters only when no decision was recorded at offer time (the refusal
2850
+ * path returns before the offer-time dial is started).
2429
2851
  * @returns {Promise<ReturnType<typeof p2pDeliveryMark>>}
2430
2852
  */
2431
- async function planDirectDelivery(offer) {
2853
+ async function planDirectDelivery(offer, status = null) {
2854
+ const taskId = typeof offer?.task_id === 'string' && offer.task_id !== '' ? offer.task_id : null;
2855
+
2856
+ if (taskId !== null && channelFor(taskId) === null) {
2857
+ // ---- the result-path reverse dial (v0.4.x) ---------------------------
2858
+ // No channel: the offer came over the relay, or it came over a channel the dispatcher has since
2859
+ // closed (it closes the one it pushed the offer over, immediately after the push). Before the
2860
+ // relay carries the result -- which it still does, unchanged, in every failure case -- this
2861
+ // machine tries once, bounded, to dial the dispatcher itself. The attempt is *reused* when the
2862
+ // offer-time dial is still running or already finished, so a task pays for one window at most,
2863
+ // never two.
2864
+ if (!(status === 'refused' && p2pReverseDials.get(taskId) === undefined)) {
2865
+ const record = startReverseDial(offer, { stage: 'result', afterLiveChannel: true });
2866
+ if (record !== null && record.attempted === true && record.promise !== null) {
2867
+ // Bounded by construction: `dial()` resolves at the tuning window or on the first answer.
2868
+ await record.promise;
2869
+ }
2870
+ }
2871
+ }
2872
+
2432
2873
  if (channelFor(offer?.task_id) === null) return p2pDeliveryMark();
2433
2874
  const direct = await sendResultDirect(offer.task_id);
2434
2875
  if (direct.delivered) {
@@ -2512,18 +2953,25 @@ export function createAgent(options) {
2512
2953
  async function finish(context) {
2513
2954
  const anchorsAfter = context.anchorsAfter ?? (await safePostState(context.offer, context.warnings));
2514
2955
  const toolchain = await toolchainInfo();
2956
+ // The bounded wait happens *before* the envelope is built, because `result_path` has to be
2957
+ // inside the envelope when `envelope_sha256` is taken; see `sendResultDirect`.
2958
+ const p2pDelivery = context.p2pDelivery ?? (await planDirectDelivery(context.offer, context.status));
2959
+ // Read once, here, and handed to `makeEnvelope`: the envelope has to describe the dial that
2960
+ // decided its `result_path`, and the record is released with the channel a moment later.
2961
+ const p2pReverse = context.p2pReverse ?? reverseDialSummary(context.offer?.task_id);
2515
2962
  const envelope = makeEnvelope({
2516
2963
  ...context,
2517
2964
  anchorsAfter,
2518
2965
  toolchain,
2519
2966
  comparePolicy: context.offer.compare_policy,
2520
- // The bounded wait happens *before* the envelope is built, because `result_path` has to be
2521
- // inside the envelope when `envelope_sha256` is taken; see `sendResultDirect`.
2522
- p2pDelivery: context.p2pDelivery ?? (await planDirectDelivery(context.offer)),
2967
+ p2pDelivery,
2968
+ p2pReverse,
2523
2969
  });
2524
2970
  await deliver(envelope);
2525
2971
  // The attempt is over. The channel lingers (bounded) so a duplicate of this attempt can still be
2526
- // answered on it, and is released afterwards.
2972
+ // answered on it, and is released afterwards; the dial's own bookkeeping is consumed by the
2973
+ // envelope above and released with it, so a long-lived agent keeps one record per *live* task.
2974
+ releaseDialRecord(envelope.task_id);
2527
2975
  scheduleChannelRelease(envelope.task_id);
2528
2976
  return envelope;
2529
2977
  }
@@ -2869,7 +3317,16 @@ export function createAgent(options) {
2869
3317
  /** Drop a queued task and abort a running one (§3.4). Shared by both transports. */
2870
3318
  function cancelTask(taskId, reason) {
2871
3319
  const index = queue.findIndex((item) => item.task_id === taskId);
2872
- if (index !== -1) queue.splice(index, 1);
3320
+ if (index !== -1) {
3321
+ queue.splice(index, 1);
3322
+ // A task cancelled before it ran never reaches `finish`, which is where a task's direct-path
3323
+ // bookkeeping is normally released: the channel its offer arrived on (or the one its own dial
3324
+ // opened) and the dial record. Nothing will ever be answered on that channel now, so it is let
3325
+ // go here rather than left holding a mapping until the node closes.
3326
+ releaseChannel(taskId);
3327
+ log('info', `task.cancel for queued ${taskId}: dropped before it ran`);
3328
+ return;
3329
+ }
2873
3330
  if (state.current && state.current.task_id === taskId) {
2874
3331
  log('warn', `task.cancel for running ${taskId}: ${reason ?? ''}`);
2875
3332
  state.current.controller.abort();