@cohortapp/agent-sdk 2.14.0 → 2.15.0

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.
@@ -1060,7 +1060,27 @@ export function sessionLiveness(hb, nowMs, opts = {}) {
1060
1060
  const staleMs = Number.isFinite(opts.staleMs) ? opts.staleMs : SESSION_STALE_MS;
1061
1061
  const ts = typeof hb.ts === "number" ? hb.ts : Date.parse(hb.ts);
1062
1062
  const age = nowMs - ts;
1063
- const live = Number.isFinite(age) && age >= 0 && age <= staleMs;
1063
+ // SYMMETRIC around now, matching lib/session/liveness.mjs#heartbeatLiveness,
1064
+ // which this used to contradict: it accepted `-staleMs < age < staleMs` while
1065
+ // this demanded `age >= 0`, so the same heartbeat could be live to the session
1066
+ // runtime and dead to the beat.
1067
+ //
1068
+ // The asymmetry was not theoretical. A039 flapped between live and not-live
1069
+ // across consecutive beats on 2026-09-10/11 while its session kept running,
1070
+ // because a heartbeat written microseconds before the wall clock stepped back
1071
+ // reads as future-dated, and ANY future dating failed `age >= 0`. The seat is
1072
+ // within 30 s of the server, so this is ordinary same-machine clock motion
1073
+ // (NTP correcting a box 95 days up), not a broken clock — and a front-door
1074
+ // signal that flickers is worse than none, because it is the one field an
1075
+ // operator uses to decide whether to walk to a machine.
1076
+ //
1077
+ // Beyond the window a future heartbeat is still NOT live, and sessionNote
1078
+ // reports it as such ("last beat is in the future (clock skew)") rather than
1079
+ // silently believing a timestamp it cannot trust.
1080
+ // Strict on BOTH edges, because that is what heartbeatLiveness does
1081
+ // (`age >= staleMs` and `age <= -staleMs` are each "not live"). An edge that
1082
+ // differs by one millisecond is still two answers to one question.
1083
+ const live = Number.isFinite(age) && Math.abs(age) < staleMs;
1064
1084
  return { frontDoor: "session", sessionLive: live };
1065
1085
  }
1066
1086
 
@@ -33,6 +33,8 @@ import {
33
33
  ramLabel,
34
34
  collectSpend24h,
35
35
  collectInventory,
36
+ sessionLiveness,
37
+ SESSION_STALE_MS,
36
38
  _internals,
37
39
  } from "./collect.mjs";
38
40
 
@@ -1218,3 +1220,55 @@ test("collectStatus: machine.sessionNote appears ONLY when the front door is dow
1218
1220
  }
1219
1221
  } finally { rmSync(root, { recursive: true, force: true }); }
1220
1222
  });
1223
+
1224
+ /**
1225
+ * The front-door signal must not flicker while the session is running.
1226
+ *
1227
+ * This function and `lib/session/liveness.mjs#heartbeatLiveness` answer one
1228
+ * question — "is the main session alive" — and they used to disagree: the
1229
+ * runtime accepted a heartbeat up to `staleMs` either side of now, this one
1230
+ * demanded `age >= 0`. So a heartbeat written microseconds before the wall
1231
+ * clock stepped back read as future-dated and flipped the seat to DOWN, while
1232
+ * the session carried on. A039 did exactly that across consecutive beats on
1233
+ * 2026-09-10/11 with its session healthy and its clock within 30 s of the
1234
+ * server. Symmetric now, and pinned here because the asymmetry was invisible:
1235
+ * every test used a past-dated heartbeat.
1236
+ */
1237
+ test("sessionLiveness tolerates clock skew symmetrically, and reports a far-future beat as not live", () => {
1238
+ const now = Date.parse("2026-09-11T12:00:00Z");
1239
+ const at = (ms) => ({ ts: new Date(now - ms).toISOString() });
1240
+
1241
+ // Fresh, and a beat from the recent past: live, as before.
1242
+ assert.equal(sessionLiveness(at(0), now).sessionLive, true);
1243
+ assert.equal(sessionLiveness(at(30_000), now).sessionLive, true);
1244
+
1245
+ // THE REGRESSION: a beat dated slightly in the FUTURE is the clock moving,
1246
+ // not the session dying. Both of these used to return false.
1247
+ assert.equal(sessionLiveness(at(-1), now).sessionLive, true, "1 ms ahead is not a dead session");
1248
+ assert.equal(sessionLiveness(at(-30_000), now).sessionLive, true, "30 s ahead is ordinary clock motion");
1249
+
1250
+ // The window is symmetric and OPEN at both edges — the runtime's rule.
1251
+ assert.equal(sessionLiveness(at(SESSION_STALE_MS - 1), now).sessionLive, true);
1252
+ assert.equal(sessionLiveness(at(-(SESSION_STALE_MS - 1)), now).sessionLive, true);
1253
+ assert.equal(sessionLiveness(at(SESSION_STALE_MS), now).sessionLive, false, "too old");
1254
+ assert.equal(sessionLiveness(at(-SESSION_STALE_MS), now).sessionLive, false, "too far ahead to believe");
1255
+
1256
+ // The front door is still named in every case a heartbeat exists.
1257
+ assert.equal(sessionLiveness(at(10 * SESSION_STALE_MS), now).frontDoor, "session");
1258
+ // No heartbeat at all remains the daemon's lane.
1259
+ assert.deepEqual(sessionLiveness(null, now), { frontDoor: "daemon", sessionLive: false });
1260
+ });
1261
+
1262
+ test("sessionLiveness agrees with the session runtime's own window", async () => {
1263
+ const { heartbeatLiveness, STALE_MS } = await import("../session/liveness.mjs");
1264
+ assert.equal(STALE_MS, SESSION_STALE_MS, "one window, two readers");
1265
+ const now = Date.parse("2026-09-11T12:00:00Z");
1266
+ for (const offset of [0, 1_000, -1_000, 45_000, -45_000, STALE_MS, -STALE_MS, STALE_MS + 1, -(STALE_MS + 1)]) {
1267
+ const hb = { ts: new Date(now - offset).toISOString(), pid: 0 };
1268
+ assert.equal(
1269
+ sessionLiveness(hb, now).sessionLive,
1270
+ heartbeatLiveness(hb, { now }).live,
1271
+ `the two readers disagree at offset ${offset} ms`
1272
+ );
1273
+ }
1274
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.14.0",
3
+ "version": "2.15.0",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {