synartesis 0.9.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -2,6 +2,69 @@
2
2
 
3
3
  What changed, and why it mattered. Dates are release dates.
4
4
 
5
+ ## 0.9.1 — 2026-09-24
6
+
7
+ This release attacks undo instead of describing it. The new tests generate the
8
+ agent sessions rather than script them, kill undo with SIGKILL partway through,
9
+ and race several agents against one approval. They found five bugs that no
10
+ hand-written scenario had reached.
11
+
12
+ ### Fixed
13
+
14
+ - **An undo that succeeded could be stuck for good.** A server that applies an
15
+ inverse and then fails before answering (a crashed handler, a gateway timeout)
16
+ was read as having refused it. The next attempt found the resource already put
17
+ back, called that drift, and every attempt after that refused on it. Undo now
18
+ reads the resource when an inverse errors, and counts it as done when the
19
+ resource is where that inverse puts it.
20
+ - **An undo killed at the wrong moment could block that action for ever.**
21
+ Claiming an action and recording which process holds it were two separate
22
+ writes, though the code's own comment said they were one transaction. A kill
23
+ between them left an action claimed by nobody, which no later undo could tell
24
+ from one still in progress. They are now one transaction.
25
+ - **An undo killed while compensating stopped on its own success.** The delete
26
+ that undoes a create went through, and the resumed undo called the record's
27
+ absence drift. It now recognises it, but only when the owner is certainly
28
+ gone and the record is certainly gone. An edit a person made instead still
29
+ stops it.
30
+ - **Running `install` from your home directory covered servers twice.** There,
31
+ a client's project config and its global one are the same file. It was listed
32
+ twice, and every server in it was drafted into the policy a second time under
33
+ another name.
34
+ - **"Covered, nothing through it yet" about a server in daily use.** The connect
35
+ screen looked up the last use under the client entry's name, not the name the
36
+ policy gives the server, and those differ when two clients list a server with
37
+ the same name.
38
+ - **A policy reading a field every object inherits** (`constructor`,
39
+ `toString`) resolved to nothing when the record lacked it, instead of saying
40
+ it was absent. The inverse then quietly left that field out of what it
41
+ restored.
42
+
43
+ ### Faster
44
+
45
+ - **Servers start together.** Every path that starts more than one server
46
+ (a proxy serving several, `undo`, `check`, `pin`) waited for each before
47
+ starting the next. Three servers that take 800 ms each answered after 2.9 s;
48
+ now it is under 1.6 s, and the order they are reported in is unchanged.
49
+ - **Each server's tools are listed once at start-up**, not three times.
50
+
51
+ ### Tests
52
+
53
+ - `tests/stress-undo.test.ts`: random sessions with awkward values (unicode,
54
+ 5,000-character strings, `__proto__`, text that looks like a template), undone
55
+ through injected crashes before and after writes, undone to random steps, and
56
+ with a person's edit that must survive.
57
+ - `tests/kill-undo.test.ts`: `synartesis undo` killed with SIGKILL the moment a
58
+ random number of actions have been undone, again and again, with a real server
59
+ and store.
60
+ - `tests/race-approval.test.ts`: eight separate agent processes retrying one
61
+ approved email in the same instant. Exactly one is sent.
62
+ - Direct tests for the code only child processes reached before: which clients
63
+ are covered, the environment an undo starts a server with, and the Linux
64
+ notifier.
65
+ - `pnpm stress` runs all three at full strength; `pnpm coverage` reports what
66
+ the suite reaches.
67
+
5
68
  ## 0.9.0 — 2026-09-24
6
69
 
7
70
  0.8.4 to 0.8.8 made Synartesis correct once you were using it. This release is
package/README.md CHANGED
@@ -609,6 +609,18 @@ pnpm check
609
609
  Every push runs that on Linux and macOS across Node 22 and 24, plus both demos,
610
610
  the installer, and a build of the desktop app.
611
611
 
612
+ ```bash
613
+ pnpm stress
614
+ ```
615
+
616
+ The same three attacks `check` runs lightly, at full strength: 500 random agent
617
+ sessions, each undone and checked byte for byte against where it started (also
618
+ undone to a random step, and with a person's edit made afterwards that must
619
+ survive); `synartesis undo` killed with SIGKILL at random points 25 times over
620
+ and run again; and eight agents racing one approval, ten times, which must send
621
+ exactly one email. A failure names its seed, and `SYNARTESIS_STRESS_SEED`
622
+ replays it. `pnpm coverage` reports what the suite reaches.
623
+
612
624
  **Windows is built and not tested.** The release attaches a Windows installer,
613
625
  and no CI job compiles or exercises it — the test matrix is Linux and macOS. It
614
626
  is expected to work, the code has no platform-specific paths outside
@@ -51,11 +51,10 @@ function launch(command, args) {
51
51
  } catch {
52
52
  }
53
53
  }
54
- function desktopNotifier(env = process.env) {
54
+ function desktopNotifier(env = process.env, os = platform()) {
55
55
  if (env["SYNARTESIS_NOTIFY"] === "0") {
56
56
  return SILENT;
57
57
  }
58
- const os = platform();
59
58
  if (os === "darwin") {
60
59
  return (notice) => {
61
60
  const { title, body } = words(notice);
@@ -901,7 +900,7 @@ var SqliteJournal = class {
901
900
  * resource is contested.
902
901
  */
903
902
  markRollingBack(actionId, from = ["applied"]) {
904
- return this.#run("markRollingBack", () => {
903
+ return this.#run("markRollingBack", () => this.#db.transaction(() => {
905
904
  const slots = from.map(() => "?").join(",");
906
905
  const result = this.#db.prepare(
907
906
  `UPDATE actions SET status = 'rolling_back' WHERE id = ? AND status IN (${slots})`
@@ -915,7 +914,7 @@ var SqliteJournal = class {
915
914
  pid = excluded.pid, claimed_at = excluded.claimed_at`
916
915
  ).run(actionId, hostname(), process.pid, (/* @__PURE__ */ new Date()).toISOString());
917
916
  return true;
918
- });
917
+ }).immediate());
919
918
  }
920
919
  leaseFor(actionId) {
921
920
  return this.#run("leaseFor", () => {
@@ -1530,7 +1529,7 @@ function walk(current, parts, at, reference) {
1530
1529
  return walk(current[segment.index], parts, at + 1, reference);
1531
1530
  }
1532
1531
  case "key": {
1533
- if (typeof current !== "object" || !(segment.key in current)) {
1532
+ if (typeof current !== "object" || !Object.hasOwn(current, segment.key)) {
1534
1533
  throw new ManifestError(`${reference} is unresolvable: ${segment.key} is absent`);
1535
1534
  }
1536
1535
  const next = Object.getOwnPropertyDescriptor(current, segment.key)?.value;
@@ -2072,6 +2071,11 @@ function explainPins(server, faults) {
2072
2071
 
2073
2072
  // src/manifest/verify.ts
2074
2073
  import { z as z3 } from "zod";
2074
+ async function listAll(upstreams) {
2075
+ return new Map(
2076
+ await Promise.all(upstreams.map(async (upstream) => [upstream.name, await toolShapes(upstream)]))
2077
+ );
2078
+ }
2075
2079
  var listSchema = z3.looseObject({
2076
2080
  tools: z3.array(
2077
2081
  z3.looseObject({
@@ -2103,11 +2107,11 @@ async function toolShapes(upstream) {
2103
2107
  } while (cursor !== void 0);
2104
2108
  return shapes;
2105
2109
  }
2106
- async function verifyAgainstServers(upstreams, manifest) {
2110
+ async function verifyAgainstServers(upstreams, manifest, listed) {
2107
2111
  const shapes = /* @__PURE__ */ new Map();
2108
2112
  const available = /* @__PURE__ */ new Map();
2109
2113
  for (const upstream of upstreams) {
2110
- const advertised = await toolShapes(upstream);
2114
+ const advertised = listed?.get(upstream.name) ?? await toolShapes(upstream);
2111
2115
  shapes.set(upstream.name, advertised);
2112
2116
  available.set(upstream.name, new Set(advertised.map((tool) => tool.name)));
2113
2117
  }
@@ -2154,10 +2158,11 @@ async function verifyAgainstServers(upstreams, manifest) {
2154
2158
  );
2155
2159
  }
2156
2160
  }
2157
- async function withoutMissingTools(upstreams, manifest) {
2161
+ async function withoutMissingTools(upstreams, manifest, listed) {
2158
2162
  const available = /* @__PURE__ */ new Map();
2159
2163
  for (const upstream of upstreams) {
2160
- available.set(upstream.name, new Set((await toolShapes(upstream)).map((tool) => tool.name)));
2164
+ const advertised = listed?.get(upstream.name) ?? await toolShapes(upstream);
2165
+ available.set(upstream.name, new Set(advertised.map((tool) => tool.name)));
2161
2166
  }
2162
2167
  const missing = (qualified) => {
2163
2168
  const target = splitQualified(qualified);
@@ -2537,6 +2542,31 @@ function refusal(error) {
2537
2542
  }
2538
2543
  return void 0;
2539
2544
  }
2545
+ async function startTogether(items, start2) {
2546
+ const settled2 = await Promise.allSettled(
2547
+ items.map(async (item) => ({ item, upstream: await start2(item) }))
2548
+ );
2549
+ const started = [];
2550
+ const failed = [];
2551
+ items.forEach((item, index) => {
2552
+ const result = settled2[index];
2553
+ if (result?.status === "fulfilled") {
2554
+ started.push(result.value.upstream);
2555
+ } else if (result !== void 0) {
2556
+ failed.push({ item, error: result.reason });
2557
+ }
2558
+ });
2559
+ return { started, failed };
2560
+ }
2561
+ async function startAll(items, start2) {
2562
+ const { started, failed } = await startTogether(items, start2);
2563
+ const first = failed[0];
2564
+ if (first !== void 0) {
2565
+ await Promise.all(started.map((upstream) => upstream.close().catch(() => void 0)));
2566
+ throw first.error;
2567
+ }
2568
+ return started;
2569
+ }
2540
2570
 
2541
2571
  // src/install/clients.ts
2542
2572
  import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync } from "fs";
@@ -2854,7 +2884,15 @@ function discover(cwd) {
2854
2884
  sites.push({ client, label: LABELS[client], format: "json", path, scope, at: ["mcpServers"] });
2855
2885
  }
2856
2886
  }
2857
- return sites;
2887
+ const seen = /* @__PURE__ */ new Set();
2888
+ return sites.filter((site) => {
2889
+ const key = `${resolve2(site.path)}\0${site.at.join("\0")}`;
2890
+ if (seen.has(key)) {
2891
+ return false;
2892
+ }
2893
+ seen.add(key);
2894
+ return true;
2895
+ });
2858
2896
  }
2859
2897
  function isRecord(value) {
2860
2898
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -3891,6 +3929,7 @@ export {
3891
3929
  splitQualified,
3892
3930
  fingerprint,
3893
3931
  pinBlock,
3932
+ listAll,
3894
3933
  toolShapes,
3895
3934
  verifyAgainstServers,
3896
3935
  withoutMissingTools,
@@ -3918,6 +3957,8 @@ export {
3918
3957
  differing,
3919
3958
  declaredNames,
3920
3959
  connectUpstream,
3960
+ startTogether,
3961
+ startAll,
3921
3962
  LOOKED_FOR,
3922
3963
  CLIENT_IDS,
3923
3964
  isClientId,
@@ -3932,4 +3973,4 @@ export {
3932
3973
  applyUninstall,
3933
3974
  clientEnvFor
3934
3975
  };
3935
- //# sourceMappingURL=chunk-AEEKBR5D.js.map
3976
+ //# sourceMappingURL=chunk-H4SPMXQW.js.map
package/dist/cli.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  isClientId,
34
34
  isWrapped,
35
35
  labelFor,
36
+ listAll,
36
37
  loadManifest,
37
38
  observeState,
38
39
  openJournal,
@@ -48,6 +49,8 @@ import {
48
49
  serversAt,
49
50
  splitQualified,
50
51
  standing,
52
+ startAll,
53
+ startTogether,
51
54
  style,
52
55
  toPayload,
53
56
  toResolvedRead,
@@ -59,7 +62,7 @@ import {
59
62
  verifyAgainstServers,
60
63
  warnUntested,
61
64
  wasRefused
62
- } from "./chunk-AEEKBR5D.js";
65
+ } from "./chunk-H4SPMXQW.js";
63
66
  import {
64
67
  DriftConflict,
65
68
  ManifestError,
@@ -372,7 +375,11 @@ ${seen}` : seen;
372
375
  }
373
376
  if (sameState(current, recordedPost.data)) {
374
377
  verified = true;
375
- } else if (sameState(current, intendedAfterInverse(action))) {
378
+ } else if (sameState(current, intendedAfterInverse(action)) || // An inverse this undo sent before it was killed, whose owner is gone:
379
+ // the resource having reached the state it produces is the inverse
380
+ // having landed. Found by SIGKILLing undo mid-compensation: the delete
381
+ // had gone through, and every later attempt called its absence drift.
382
+ action.status === "rolling_back" && journal.leaseFor(action.id)?.alive === false && looksUndone(current, action)) {
376
383
  steps.push({
377
384
  ...describeStep(action),
378
385
  kind: "already-reverted",
@@ -487,6 +494,19 @@ ${seen}` : seen;
487
494
  journal.markRolledBack(action.id);
488
495
  continue;
489
496
  }
497
+ const landed = outcome.rejected && recordedPost.success && verifyRead.success ? await landedAnyway(router, toResolvedRead(verifyRead.data), recordedPost.data, action, signal) : void 0;
498
+ if (landed !== void 0) {
499
+ journal.markRolledBack(action.id);
500
+ steps[steps.length - 1] = {
501
+ ...describeStep(action),
502
+ kind: "revert",
503
+ reason: landed,
504
+ verified,
505
+ plan,
506
+ note: `the server reported an error (${truncated(outcome.message)}), but the resource had changed as this inverse changes it`
507
+ };
508
+ continue;
509
+ }
490
510
  const halt = new RollbackHalted(action.seq, outcome.message);
491
511
  if (outcome.rejected) {
492
512
  journal.markInverseRejected(action.id, halt.message);
@@ -544,6 +564,28 @@ function overwriteText(current, action) {
544
564
  }
545
565
  return changedLines(current, intended2);
546
566
  }
567
+ async function landedAnyway(router, read, post, action, signal) {
568
+ let now;
569
+ try {
570
+ now = await observeState(router, read, signal);
571
+ } catch {
572
+ return void 0;
573
+ }
574
+ if (sameState(now, post)) {
575
+ return void 0;
576
+ }
577
+ return looksUndone(now, action) ? "done, despite the server reporting an error" : void 0;
578
+ }
579
+ function looksUndone(now, action) {
580
+ const intended2 = intendedAfterInverse(action);
581
+ if (intended2 !== void 0) {
582
+ return sameState(now, intended2);
583
+ }
584
+ return action.class === "compensable" && !now.present;
585
+ }
586
+ function truncated(text) {
587
+ return text.length > 160 ? `${text.slice(0, 157)}...` : text;
588
+ }
547
589
  function intendedAfterInverse(action) {
548
590
  return action.snapshot === void 0 ? void 0 : { present: true, value: action.snapshot };
549
591
  }
@@ -1278,7 +1320,9 @@ function scan(journal, cwd) {
1278
1320
  }
1279
1321
  const connections = Object.entries(servers).map(([server, entry]) => {
1280
1322
  const covered = isWrapped(entry);
1281
- const lastSeen = seen.get(server);
1323
+ const args = entry.args ?? [];
1324
+ const named = covered && args.includes("--server") ? args[args.indexOf("--server") + 1] : void 0;
1325
+ const lastSeen = seen.get(named ?? server);
1282
1326
  return {
1283
1327
  client: site.client,
1284
1328
  scope: site.scope,
@@ -2254,14 +2298,16 @@ async function startAsTheClientWould(manifestPath, name, spec, session) {
2254
2298
  async function runPin(argv) {
2255
2299
  const path = findManifest(flag(argv, "--manifest"));
2256
2300
  const manifest = loadManifest(path);
2257
- const shapes = /* @__PURE__ */ new Map();
2301
+ let shapes = /* @__PURE__ */ new Map();
2258
2302
  const upstreams = [];
2259
2303
  try {
2260
- for (const [name, spec] of Object.entries(manifest.servers)) {
2261
- const upstream = await startAsTheClientWould(path, name, spec);
2262
- upstreams.push(upstream);
2263
- shapes.set(name, await toolShapes(upstream));
2264
- }
2304
+ upstreams.push(
2305
+ ...await startAll(
2306
+ Object.entries(manifest.servers),
2307
+ ([name, spec]) => startAsTheClientWould(path, name, spec)
2308
+ )
2309
+ );
2310
+ shapes = await listAll(upstreams);
2265
2311
  } finally {
2266
2312
  for (const upstream of upstreams) {
2267
2313
  await upstream.close();
@@ -2321,13 +2367,17 @@ async function runCheck(argv) {
2321
2367
  const offered = /* @__PURE__ */ new Map();
2322
2368
  const trustedReads = /* @__PURE__ */ new Map();
2323
2369
  try {
2324
- for (const [name, spec] of Object.entries(manifest.servers)) {
2325
- upstreams.push(await startAsTheClientWould(path, name, spec));
2326
- }
2327
- await verifyAgainstServers(upstreams, manifest);
2370
+ upstreams.push(
2371
+ ...await startAll(
2372
+ Object.entries(manifest.servers),
2373
+ ([name, spec]) => startAsTheClientWould(path, name, spec)
2374
+ )
2375
+ );
2376
+ const listed = await listAll(upstreams);
2377
+ await verifyAgainstServers(upstreams, manifest, listed);
2328
2378
  const resolver = createPolicyResolver(manifest);
2329
2379
  for (const upstream of upstreams) {
2330
- const shapes = await toolShapes(upstream);
2380
+ const shapes = listed.get(upstream.name) ?? [];
2331
2381
  const trusted = shapes.filter(
2332
2382
  (tool) => tool.readOnly === true && trustsMarks(manifest, upstream.name) && !resolver.resolve(`${upstream.name}.${tool.name}`).matched
2333
2383
  ).map((tool) => tool.name);
@@ -3663,19 +3713,14 @@ function report(result, alreadyForcing = false, as = "") {
3663
3713
  }
3664
3714
  async function withUpstreams(manifestPath, use, only, session) {
3665
3715
  const manifest = loadManifest(manifestPath);
3666
- const upstreams = [];
3667
- const missing = [];
3716
+ const wanted = Object.entries(manifest.servers).filter(([name]) => only === void 0 || only.has(name));
3717
+ const { started, failed } = await startTogether(
3718
+ wanted,
3719
+ ([name, spec]) => startAsTheClientWould(manifestPath, name, spec, session)
3720
+ );
3721
+ const upstreams = [...started];
3722
+ const missing = failed.map(({ item: [name], error }) => `${name}: ${describe(error)}`);
3668
3723
  try {
3669
- for (const [name, spec] of Object.entries(manifest.servers)) {
3670
- if (only !== void 0 && !only.has(name)) {
3671
- continue;
3672
- }
3673
- try {
3674
- upstreams.push(await startAsTheClientWould(manifestPath, name, spec, session));
3675
- } catch (error) {
3676
- missing.push(`${name}: ${describe(error)}`);
3677
- }
3678
- }
3679
3724
  if (upstreams.length === 0 && missing.length > 0) {
3680
3725
  throw new ManifestError(`no server could be started. ${missing.join("; ")}`);
3681
3726
  }
package/dist/proxy.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  findManifest,
15
15
  fingerprint2 as fingerprint,
16
16
  isDisconnected,
17
+ listAll,
17
18
  loadManifest,
18
19
  mark,
19
20
  observeState,
@@ -23,8 +24,8 @@ import {
23
24
  qualify,
24
25
  refusal,
25
26
  runRead,
27
+ startAll,
26
28
  toPayload,
27
- toolShapes,
28
29
  trustsMarks,
29
30
  ungoverned,
30
31
  untested,
@@ -33,7 +34,7 @@ import {
33
34
  warnUntested,
34
35
  withIdempotencyKey,
35
36
  withoutMissingTools
36
- } from "./chunk-AEEKBR5D.js";
37
+ } from "./chunk-H4SPMXQW.js";
37
38
  import {
38
39
  SnapshotError,
39
40
  UpstreamError,
@@ -1303,8 +1304,8 @@ async function main() {
1303
1304
  const upstreams = [];
1304
1305
  const key = journal.fingerprintKey();
1305
1306
  const startedWith = /* @__PURE__ */ new Map();
1307
+ upstreams.push(...await startAll([...wanted], ([name, spec]) => connectUpstream(name, spec, { env: source })));
1306
1308
  for (const [name, spec] of wanted) {
1307
- upstreams.push(await connectUpstream(name, spec, { env: source }));
1308
1309
  let client;
1309
1310
  try {
1310
1311
  client = source.kind === "inherit" ? clientEnvFor(argv.manifest, name)?.env : void 0;
@@ -1316,7 +1317,8 @@ async function main() {
1316
1317
  fingerprints: fingerprint(key, upstreamEnv(name, spec, source), declaredNames(spec, client))
1317
1318
  });
1318
1319
  }
1319
- const { manifest: served, disabled } = await withoutMissingTools(upstreams, manifest);
1320
+ const listed = await listAll(upstreams);
1321
+ const { manifest: served, disabled } = await withoutMissingTools(upstreams, manifest, listed);
1320
1322
  for (const line of disabled) {
1321
1323
  log.warn(line);
1322
1324
  }
@@ -1325,14 +1327,15 @@ async function main() {
1325
1327
  argv.server === void 0 ? served : {
1326
1328
  ...served,
1327
1329
  tools: served.tools.filter((rule) => rule.match.startsWith(`${String(argv.server)}.`))
1328
- }
1330
+ },
1331
+ listed
1329
1332
  );
1330
1333
  const uncovered = ungoverned(
1331
1334
  served,
1332
- new Map(await Promise.all(upstreams.map(async (upstream) => [
1335
+ new Map(upstreams.map((upstream) => [
1333
1336
  upstream.name,
1334
- (await toolShapes(upstream)).filter((tool) => !(tool.readOnly === true && trustsMarks(served, upstream.name))).map((tool) => tool.name)
1335
- ])))
1337
+ (listed.get(upstream.name) ?? []).filter((tool) => !(tool.readOnly === true && trustsMarks(served, upstream.name))).map((tool) => tool.name)
1338
+ ]))
1336
1339
  );
1337
1340
  for (const entry of uncovered) {
1338
1341
  log.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synartesis",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "An undo layer for AI agents.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -58,6 +58,8 @@
58
58
  "demo": "tsup --silent && ./demo/filesystem-demo.sh",
59
59
  "demo:memory": "tsup --silent && ./demo/memory-demo.sh",
60
60
  "check": "pnpm build && pnpm typecheck && pnpm lint && pnpm test",
61
+ "stress": "tsup --silent && SYNARTESIS_STRESS_SESSIONS=500 SYNARTESIS_KILL_ROUNDS=25 SYNARTESIS_RACE_ROUNDS=10 vitest run tests/stress-undo.test.ts tests/kill-undo.test.ts tests/race-approval.test.ts",
62
+ "coverage": "tsup --silent && vitest run --testTimeout=240000 --coverage --coverage.provider=v8 --coverage.include='src/**' --coverage.reportOnFailure",
61
63
  "prepublishOnly": "pnpm check"
62
64
  },
63
65
  "dependencies": {
@@ -93,6 +95,7 @@
93
95
  "@types/react": "^19.3.0",
94
96
  "@types/react-dom": "^19.3.0",
95
97
  "@vitejs/plugin-react": "^6.1.1",
98
+ "@vitest/coverage-v8": "4.1.11",
96
99
  "electron": "^44.3.0",
97
100
  "electron-builder": "^26.15.3",
98
101
  "eslint": "^10.8.1",