@kybernesis/create 0.15.5 → 0.15.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.js CHANGED
@@ -6,6 +6,8 @@ import { bold, capture, dim, green, parseEnv, red, yellow } from "./util.js";
6
6
  import { diagnoseManageRestart, findMatchingAgentServiceUnit } from "./systemd.js";
7
7
  import { inspectEveAgent } from "./agent-limits.js";
8
8
  import { classifyModelReach } from "./model-reach.js";
9
+ import { countAbandonedRuns } from "./run-store.js";
10
+ import { describeStaleSidecar, newestPackageChange, staleSidecars } from "./stale-sidecar.js";
9
11
  import { checkSelfVersion } from "./self-version.js";
10
12
  const MARK = {
11
13
  pass: green("✓"),
@@ -116,6 +118,17 @@ async function head(url, headers) {
116
118
  return null;
117
119
  }
118
120
  }
121
+ /** Running units that belong to a Kybernesis agent on this host. */
122
+ function listAgentUnits() {
123
+ const out = capture("systemctl", ["list-units", "--type=service", "--state=running", "--no-legend", "--plain"]);
124
+ if (!out)
125
+ return [];
126
+ return out
127
+ .split("\n")
128
+ .map((line) => line.trim().split(/\s+/)[0] ?? "")
129
+ .filter((unit) => /-(agent|buzz-bridge)\.service$/.test(unit))
130
+ .map((unit) => unit.replace(/\.service$/, ""));
131
+ }
119
132
  export async function doctor() {
120
133
  const cwd = process.cwd();
121
134
  const installedAgentService = findMatchingAgentServiceUnit(cwd);
@@ -125,6 +138,44 @@ export async function doctor() {
125
138
  if (selfVersion.kind === "stale") {
126
139
  add("warn", selfVersion.message, selfVersion.fix);
127
140
  }
141
+ /**
142
+ * Durable runs nothing will ever finish. They are replayed at every boot and
143
+ * eve removes none of them, so this only ever grows until something retires
144
+ * them. Reported rather than fixed: the remedy restarts the agent, which is
145
+ * not something a diagnostic should do behind someone's back.
146
+ */
147
+ /**
148
+ * Services still running the code they loaded at start.
149
+ *
150
+ * An agent host has more than one: deploy.sh restarts the agent, and the Buzz
151
+ * bridge is a separate unit it never touches. Both bridges on the reference
152
+ * fleet ran four days on a superseded package while the fix sat on disk, so
153
+ * two changes signed off as deployed were live nowhere. Nothing looked
154
+ * broken — the old code simply kept doing what it did.
155
+ */
156
+ const packageChange = newestPackageChange(cwd);
157
+ const started = new Map();
158
+ for (const unit of listAgentUnits()) {
159
+ const stamp = capture("systemctl", ["show", "-p", "ActiveEnterTimestamp", "--value", unit])?.trim();
160
+ const at = stamp ? new Date(stamp) : null;
161
+ if (at && !Number.isNaN(at.getTime()))
162
+ started.set(unit, at);
163
+ }
164
+ for (const stale of staleSidecars({ started, packageChange })) {
165
+ add("fail", `${stale.service} is running superseded code`, describeStaleSidecar(stale));
166
+ }
167
+ if (started.size > 0 && staleSidecars({ started, packageChange }).length === 0) {
168
+ add("pass", `every service is running the code currently installed (${started.size} checked)`);
169
+ }
170
+ const runs = countAbandonedRuns(cwd);
171
+ if (runs) {
172
+ if (runs.abandoned === 0) {
173
+ add("pass", `durable run store is clean (${runs.total} runs, ${runs.held} held by a hook)`);
174
+ }
175
+ else {
176
+ add("warn", `${runs.abandoned} abandoned durable run(s) of ${runs.total} — replayed at every boot, never removed`, "scripts/safe-restart.sh, or reclaimAbandonedRuns() from @kybernesis/exe. Runs a hook still holds (routine sessions) are never retired.");
177
+ }
178
+ }
128
179
  // ── project shape ──────────────────────────────────────────────────────
129
180
  if (!existsSync(join(cwd, "agent"))) {
130
181
  console.error(red("Not an eve agent project (no agent/ directory). Run inside the agent repo."));
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Picking the right build of a per-eve-line package.
3
+ *
4
+ * `@kybernesis/*` packages ship one build per eve line, and the version numbers
5
+ * are NOT ordered by line: `@kybernesis/voice` 0.1.2 is the eve 0.49 build and
6
+ * was published AFTER 0.1.1, which is the 0.51 build. So `npm view <pkg>
7
+ * version` — npm's `latest` — is routinely the wrong package for a given host,
8
+ * and installing it puts an agent on a build compiled against an eve API it is
9
+ * not running.
10
+ *
11
+ * That failure is silent. The package installs, the agent builds, the service
12
+ * starts, and one code path breaks later at runtime. It is how
13
+ * `@kybernesis/notify` took an agent down and how two agents ran a 0.51 build of
14
+ * `@kybernesis/manage` on eve 0.49 for days without anyone noticing.
15
+ *
16
+ * So an upgrade must never ask for "latest". It must ask for the newest version
17
+ * whose declared eve peer range contains the eve this host actually runs.
18
+ */
19
+ /** A published version and the eve range it declares. */
20
+ export interface Candidate {
21
+ version: string;
22
+ /** `peerDependencies.eve`, or undefined when the package declares none. */
23
+ peer?: string;
24
+ }
25
+ /**
26
+ * Compare semver-ish versions numerically.
27
+ *
28
+ * Numerically, because these are picked by sorting and string order puts 0.8.9
29
+ * above 0.8.10 — which would hand a host the wrong build. A prerelease sorts
30
+ * BELOW its release (0.1.2-rc < 0.1.2), so a release candidate is never chosen
31
+ * over the release it precedes.
32
+ */
33
+ export declare function versionCompare(a: string, b: string): number;
34
+ /**
35
+ * Whether an eve version falls inside a declared peer range.
36
+ *
37
+ * Deliberately narrow: it understands the two forms these packages actually
38
+ * publish — `>=X <Y` and a caret — and returns false for anything it does not
39
+ * recognise. Guessing at a range it cannot parse is how a wrong build gets
40
+ * installed, and "I do not know" must not read as "yes".
41
+ */
42
+ export declare function rangeContains(range: string | undefined, eve: string): boolean;
43
+ /**
44
+ * The newest published build that supports this host's eve.
45
+ *
46
+ * Returns undefined when no published version declares support — which is a
47
+ * real answer and must be surfaced, not smoothed over by falling back to
48
+ * latest. `@kybernesis/voice` had NO eve 0.49 build for weeks while two agents
49
+ * on 0.49 ran its 0.51 one.
50
+ */
51
+ export declare function newestOnLine(candidates: readonly Candidate[], eve: string): string | undefined;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Picking the right build of a per-eve-line package.
3
+ *
4
+ * `@kybernesis/*` packages ship one build per eve line, and the version numbers
5
+ * are NOT ordered by line: `@kybernesis/voice` 0.1.2 is the eve 0.49 build and
6
+ * was published AFTER 0.1.1, which is the 0.51 build. So `npm view <pkg>
7
+ * version` — npm's `latest` — is routinely the wrong package for a given host,
8
+ * and installing it puts an agent on a build compiled against an eve API it is
9
+ * not running.
10
+ *
11
+ * That failure is silent. The package installs, the agent builds, the service
12
+ * starts, and one code path breaks later at runtime. It is how
13
+ * `@kybernesis/notify` took an agent down and how two agents ran a 0.51 build of
14
+ * `@kybernesis/manage` on eve 0.49 for days without anyone noticing.
15
+ *
16
+ * So an upgrade must never ask for "latest". It must ask for the newest version
17
+ * whose declared eve peer range contains the eve this host actually runs.
18
+ */
19
+ /**
20
+ * Compare semver-ish versions numerically.
21
+ *
22
+ * Numerically, because these are picked by sorting and string order puts 0.8.9
23
+ * above 0.8.10 — which would hand a host the wrong build. A prerelease sorts
24
+ * BELOW its release (0.1.2-rc < 0.1.2), so a release candidate is never chosen
25
+ * over the release it precedes.
26
+ */
27
+ export function versionCompare(a, b) {
28
+ const split = (v) => {
29
+ const dash = v.indexOf("-");
30
+ const core = (dash === -1 ? v : v.slice(0, dash)).split(".").map((p) => Number(p) || 0);
31
+ return { core, pre: dash !== -1 };
32
+ };
33
+ const pa = split(a);
34
+ const pb = split(b);
35
+ for (let i = 0; i < Math.max(pa.core.length, pb.core.length); i += 1) {
36
+ const x = pa.core[i] ?? 0;
37
+ const y = pb.core[i] ?? 0;
38
+ if (x !== y)
39
+ return x < y ? -1 : 1;
40
+ }
41
+ if (pa.pre === pb.pre)
42
+ return 0;
43
+ return pa.pre ? -1 : 1;
44
+ }
45
+ /**
46
+ * Whether an eve version falls inside a declared peer range.
47
+ *
48
+ * Deliberately narrow: it understands the two forms these packages actually
49
+ * publish — `>=X <Y` and a caret — and returns false for anything it does not
50
+ * recognise. Guessing at a range it cannot parse is how a wrong build gets
51
+ * installed, and "I do not know" must not read as "yes".
52
+ */
53
+ export function rangeContains(range, eve) {
54
+ if (!range)
55
+ return false;
56
+ const bounded = /^>=\s*([0-9][^\s]*)\s*<\s*([0-9][^\s]*)$/.exec(range.trim());
57
+ if (bounded) {
58
+ return versionCompare(eve, bounded[1]) >= 0 && versionCompare(eve, bounded[2]) < 0;
59
+ }
60
+ const caret = /^\^([0-9][^\s]*)$/.exec(range.trim());
61
+ if (caret) {
62
+ const base = caret[1].split(".");
63
+ const major = Number(base[0] ?? 0);
64
+ const minor = Number(base[1] ?? 0);
65
+ // ^0.x is minor-locked, as npm treats it.
66
+ const upper = major === 0 ? `0.${minor + 1}.0` : `${major + 1}.0.0`;
67
+ return versionCompare(eve, caret[1]) >= 0 && versionCompare(eve, upper) < 0;
68
+ }
69
+ const atLeast = /^>=\s*([0-9][^\s]*)$/.exec(range.trim());
70
+ if (atLeast)
71
+ return versionCompare(eve, atLeast[1]) >= 0;
72
+ return false;
73
+ }
74
+ /**
75
+ * The newest published build that supports this host's eve.
76
+ *
77
+ * Returns undefined when no published version declares support — which is a
78
+ * real answer and must be surfaced, not smoothed over by falling back to
79
+ * latest. `@kybernesis/voice` had NO eve 0.49 build for weeks while two agents
80
+ * on 0.49 ran its 0.51 one.
81
+ */
82
+ export function newestOnLine(candidates, eve) {
83
+ const supported = candidates.filter((c) => rangeContains(c.peer, eve));
84
+ if (supported.length === 0)
85
+ return undefined;
86
+ return supported.sort((a, b) => versionCompare(a.version, b.version)).at(-1)?.version;
87
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How many durable runs this host is carrying that nothing will ever finish
3
+ * (KYB-546).
4
+ *
5
+ * eve leaves a run in `running` when a session, turn or task is abandoned, and
6
+ * ships nothing that retires them. They accumulate — four figures per host after
7
+ * a month — and every one is replayed at boot. After a durable-runtime change
8
+ * those replays diverge, are declared CORRUPTED_EVENT_LOG, and the conversation
9
+ * is dead for good.
10
+ *
11
+ * Reimplemented here rather than imported from @kybernesis/exe: doctor runs in
12
+ * repos that do not depend on exe, and a diagnostic that cannot run where the
13
+ * problem is is not a diagnostic. It only counts — retiring is exe's job.
14
+ */
15
+ export interface RunStoreCount {
16
+ abandoned: number;
17
+ /** Held by a hook token: routine sessions live here and must never be retired. */
18
+ held: number;
19
+ total: number;
20
+ }
21
+ /** Count abandoned runs under an agent's `.eve/.workflow-data`, or null when there is no store. */
22
+ export declare function countAbandonedRuns(cwd: string, olderThanDays?: number, now?: Date): RunStoreCount | null;
@@ -0,0 +1,63 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /** Count abandoned runs under an agent's `.eve/.workflow-data`, or null when there is no store. */
4
+ export function countAbandonedRuns(cwd, olderThanDays = 2, now = new Date()) {
5
+ const data = join(cwd, ".eve", ".workflow-data");
6
+ const runsDir = join(data, "runs");
7
+ let names;
8
+ try {
9
+ names = readdirSync(runsDir).filter((n) => n.endsWith(".json"));
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ const held = new Set();
15
+ const walk = (dir) => {
16
+ let entries;
17
+ try {
18
+ entries = readdirSync(dir, { withFileTypes: true });
19
+ }
20
+ catch {
21
+ return;
22
+ }
23
+ for (const e of entries) {
24
+ const full = join(dir, e.name);
25
+ if (e.isDirectory()) {
26
+ walk(full);
27
+ continue;
28
+ }
29
+ if (!e.name.endsWith(".json"))
30
+ continue;
31
+ try {
32
+ const d = JSON.parse(readFileSync(full, "utf8"));
33
+ if (typeof d.runId === "string" && d.runId !== "")
34
+ held.add(d.runId);
35
+ }
36
+ catch {
37
+ // Unreadable hook: count nothing rather than guess a run is unheld.
38
+ }
39
+ }
40
+ };
41
+ walk(join(data, "hooks"));
42
+ const threshold = olderThanDays * 86_400_000;
43
+ const out = { abandoned: 0, held: 0, total: names.length };
44
+ for (const name of names) {
45
+ let run;
46
+ try {
47
+ run = JSON.parse(readFileSync(join(runsDir, name), "utf8"));
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ if (run.status !== "running")
53
+ continue;
54
+ if (typeof run.runId === "string" && held.has(run.runId)) {
55
+ out.held += 1;
56
+ continue;
57
+ }
58
+ const updated = typeof run.updatedAt === "string" ? Date.parse(run.updatedAt) : NaN;
59
+ if (Number.isFinite(updated) && now.getTime() - updated >= threshold)
60
+ out.abandoned += 1;
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Services that are still running the code they loaded at start, after the
3
+ * package on disk has moved underneath them.
4
+ *
5
+ * Node reads a module once, at process start. Upgrading a package therefore
6
+ * changes nothing in a service that was already running — it keeps executing
7
+ * the old code from memory while the new version sits on disk, indefinitely.
8
+ *
9
+ * An agent host has more than one service. `deploy.sh` restarts the agent, and
10
+ * the Buzz bridge is a separate long-lived unit it does not touch. On the
11
+ * reference fleet both bridges ran for four days on `@kybernesis/buzz` 0.9.2
12
+ * while 0.9.9 was installed, so two fixes that had been signed off as deployed
13
+ * were live nowhere. Nothing was broken enough to notice; the bridges simply
14
+ * kept doing what the old code did.
15
+ *
16
+ * Checking the FILES is what hid it. The only honest question is whether the
17
+ * process started after the package changed.
18
+ */
19
+ export interface StaleSidecar {
20
+ service: string;
21
+ /** When the unit last started. */
22
+ startedAt: Date;
23
+ /** The newest package change it has not picked up. */
24
+ packageChangedAt: Date;
25
+ /** The package whose change is newest, for the message. */
26
+ packageName: string;
27
+ }
28
+ /** The newest mtime across installed @kybernesis packages, and which one it was. */
29
+ export declare function newestPackageChange(cwd: string): {
30
+ at: Date;
31
+ name: string;
32
+ } | null;
33
+ /**
34
+ * Compare each running unit's start time against the newest package change.
35
+ *
36
+ * `started` maps a unit name to when it started — supplied by the caller so
37
+ * this stays testable and does not shell out.
38
+ *
39
+ * A grace margin avoids flagging the restart that an install performs itself:
40
+ * a service restarted within a minute of the package landing has the new code.
41
+ */
42
+ export declare function staleSidecars(input: {
43
+ started: ReadonlyMap<string, Date>;
44
+ packageChange: {
45
+ at: Date;
46
+ name: string;
47
+ } | null;
48
+ graceMs?: number;
49
+ }): StaleSidecar[];
50
+ /** One line per stale service, naming the fix. */
51
+ export declare function describeStaleSidecar(stale: StaleSidecar): string;
@@ -0,0 +1,56 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /** The newest mtime across installed @kybernesis packages, and which one it was. */
4
+ export function newestPackageChange(cwd) {
5
+ const scope = join(cwd, "node_modules", "@kybernesis");
6
+ let newest = null;
7
+ let entries;
8
+ try {
9
+ entries = readdirSync(scope);
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ for (const name of entries) {
15
+ try {
16
+ // The manifest is rewritten on every install of that package, so its
17
+ // mtime is when the package last changed on this host.
18
+ const at = statSync(join(scope, name, "package.json")).mtime;
19
+ if (!newest || at > newest.at)
20
+ newest = { at, name: `@kybernesis/${name}` };
21
+ }
22
+ catch {
23
+ // A half-removed package tells us nothing; skip it.
24
+ }
25
+ }
26
+ return newest;
27
+ }
28
+ /**
29
+ * Compare each running unit's start time against the newest package change.
30
+ *
31
+ * `started` maps a unit name to when it started — supplied by the caller so
32
+ * this stays testable and does not shell out.
33
+ *
34
+ * A grace margin avoids flagging the restart that an install performs itself:
35
+ * a service restarted within a minute of the package landing has the new code.
36
+ */
37
+ export function staleSidecars(input) {
38
+ const change = input.packageChange;
39
+ if (!change)
40
+ return [];
41
+ const grace = input.graceMs ?? 60_000;
42
+ const out = [];
43
+ for (const [service, startedAt] of input.started) {
44
+ if (startedAt.getTime() >= change.at.getTime() - grace)
45
+ continue;
46
+ out.push({ service, startedAt, packageChangedAt: change.at, packageName: change.name });
47
+ }
48
+ return out;
49
+ }
50
+ /** One line per stale service, naming the fix. */
51
+ export function describeStaleSidecar(stale) {
52
+ const started = stale.startedAt.toISOString().slice(0, 16).replace("T", " ");
53
+ const changed = stale.packageChangedAt.toISOString().slice(0, 16).replace("T", " ");
54
+ return (`${stale.service} started ${started} but ${stale.packageName} changed ${changed} — ` +
55
+ `it is still running the code it loaded at start. Restart it: sudo systemctl restart ${stale.service}`);
56
+ }
package/dist/upgrade.js CHANGED
@@ -7,6 +7,7 @@ import { upsertEnv } from "./envfile.js";
7
7
  import { reconcileHostArtifact } from "./host-artifacts.js";
8
8
  import { assessPeerTree, staleRangeWarning } from "./peer-tree.js";
9
9
  import { dockerPruneCronArtifact } from "./docker-prune-cron.js";
10
+ import { newestOnLine } from "./on-line-version.js";
10
11
  import { findMatchingAgentServiceUnit, repairManageRestart } from "./systemd.js";
11
12
  import { repairTerminalSandboxCleanupHooks } from "./sandbox-cleanup.js";
12
13
  import { repairRemovedDefaultTools as repairRemovedDefaultTools_ } from "./removed-default-tools.js";
@@ -753,6 +754,44 @@ function installedPackageValue(cwd, name, expression) {
753
754
  function targetEvePeer(name, version) {
754
755
  return capture("npm", ["view", `${name}@${version}`, "peerDependencies.eve"])?.trim() || undefined;
755
756
  }
757
+ /**
758
+ * The newest published build of a @kybernesis package that supports a given eve.
759
+ *
760
+ * NOT `npm view <name> version`. These packages ship one build per eve line and
761
+ * the versions are not ordered by line — voice 0.1.2 is the eve 0.49 build and
762
+ * was published after 0.1.1, the 0.51 build — so npm's `latest` is routinely the
763
+ * wrong package. Installing it puts the agent on a build compiled against an eve
764
+ * API it is not running, which fails at neither install nor build, only later
765
+ * and only on one path. That is how an 0.51 build of @kybernesis/manage ran on
766
+ * two eve 0.49 agents for days, and how @kybernesis/notify took one down.
767
+ *
768
+ * Returns undefined when nothing published supports this eve — a real answer,
769
+ * and one the caller must report rather than paper over.
770
+ */
771
+ function newestBuildForEve(name, eve) {
772
+ const raw = capture("npm", ["view", name, "versions", "--json"]);
773
+ if (!raw)
774
+ return undefined;
775
+ let versions;
776
+ try {
777
+ const parsed = JSON.parse(raw);
778
+ versions = Array.isArray(parsed) ? parsed : [String(parsed)];
779
+ }
780
+ catch {
781
+ return undefined;
782
+ }
783
+ // Newest first, so the usual case stops after one lookup.
784
+ const ordered = [...versions].reverse();
785
+ const candidates = [];
786
+ for (const version of ordered) {
787
+ const peer = targetEvePeer(name, version);
788
+ candidates.push({ version, ...(peer ? { peer } : {}) });
789
+ const hit = newestOnLine(candidates, eve);
790
+ if (hit)
791
+ return hit;
792
+ }
793
+ return undefined;
794
+ }
756
795
  function dependencyEntries(pkg) {
757
796
  const entries = [];
758
797
  for (const section of ["dependencies", "devDependencies"]) {
@@ -879,9 +918,20 @@ export async function upgrade(options = {}) {
879
918
  continue;
880
919
  }
881
920
  installedVersions.set(name, installed);
882
- const target = name === "eve" ? EVE_VERSION : capture("npm", ["view", name, "version"])?.trim();
921
+ // eve goes to the certified pin. Everything else goes to the newest build
922
+ // that supports the eve this host will be running — NOT npm's `latest`,
923
+ // which for a per-eve-line package is routinely a build for a different
924
+ // line and installs without complaint.
925
+ const target = name === "eve"
926
+ ? EVE_VERSION
927
+ : name.startsWith("@kybernesis/")
928
+ ? newestBuildForEve(name, EVE_VERSION)
929
+ : capture("npm", ["view", name, "version"])?.trim();
883
930
  if (!target) {
884
- console.log(` ${yellow("!")} ${name}: could not resolve target version`);
931
+ console.log(` ${yellow("!")} ${name}: ` +
932
+ (name.startsWith("@kybernesis/")
933
+ ? `no published build declares support for eve ${EVE_VERSION} — leaving it alone`
934
+ : "could not resolve target version"));
885
935
  unresolved.add(name);
886
936
  continue;
887
937
  }
@@ -891,7 +941,7 @@ export async function upgrade(options = {}) {
891
941
  targetPeers.set(name, targetEvePeer(name, target));
892
942
  }
893
943
  if (installed === target) {
894
- console.log(` ${green("✓")} ${name}@${installed} ${dim(name === "eve" ? "(certified)" : "(latest)")}`);
944
+ console.log(` ${green("✓")} ${name}@${installed} ${dim(name === "eve" ? "(certified)" : name.startsWith("@kybernesis/") ? `(newest for eve ${EVE_VERSION})` : "(latest)")}`);
895
945
  }
896
946
  else if (name === "eve" && versionLt(EVE_VERSION, installed)) {
897
947
  console.log(` ${yellow("!")} eve@${installed} is AHEAD of the certified ${EVE_VERSION} ${dim("— unsupported territory")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/create",
3
- "version": "0.15.5",
3
+ "version": "0.15.7",
4
4
  "description": "The Kybernesis agent scaffolder and FDE toolkit: one command to a governed, remembering, multiplayer, self-testing eve agent — plus doctor and upgrade.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",