@shardworks/spider-apparatus 0.1.224 → 0.1.226

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/README.md CHANGED
@@ -53,6 +53,19 @@ CrawlResult variants:
53
53
  | `'engine-grafted'` | Engine injected additional engines into the pipeline |
54
54
  | `'rig-completed'` | Rig reached terminal or stuck state (completed, stuck, failed, or cancelled) |
55
55
  | `'rig-blocked'` | All forward progress stalled; rig entered blocked status |
56
+ | `'gated'` | Spawn was deferred — the writ has outbound `spider.follows` blockers (includes the case where a blocker reached `failed` and the writ was cascaded to `stuck`) |
57
+ | `'writ-unstuck'` | `autoUnstick` phase returned a previously-Spider-stuck writ to `open` because its recorded blockers resolved |
58
+
59
+ ### Dispatch gating via `spider.follows`
60
+
61
+ Spider contributes a `spider.follows` link kind and consults outbound `spider.follows` links when deciding whether to spawn a rig for an open writ. The gate is evaluated in `trySpawn` before any rig is created:
62
+
63
+ - If any direct outbound `spider.follows` target is still `new`, `open`, or `stuck` — the writ is held. `trySpawn` emits a `'gated'` result and the writ stays `open` until a later poll.
64
+ - If any direct outbound target is `failed` — the writ is cascaded to `stuck`. A `status.spider` record is written with `stuckCause: 'failed-blocker'` and the ids of every failed blocker. The resolution text is `Blocked by failed dependency: <short-id>` (or `Blocked by failed dependencies: …` when plural).
65
+ - If the full transitive `spider.follows` walk from the writ visits a cycle — the writ is cascaded to `stuck` with `stuckCause: 'cycle'` and the cycle members as blockers. The resolution text is `Detected spider.follows cycle: <id> → <id> → … → <id>`.
66
+ - Only when every direct outbound target is in a terminal-success state (`completed`, or `cancelled`) does the writ proceed to rig spawn.
67
+
68
+ Before `trySpawn`, each crawl tick runs an `autoUnstick` pass that re-evaluates every writ whose `status.spider.stuckCause` is set by this plugin. When the recorded cause resolves (all `failed-blocker` ids are now `completed`/`cancelled`, or any `cycle` member has moved out of `open`/`stuck`), the writ is returned to `open` and emits a `'writ-unstuck'` result. Engine-cascade `stuck` writs (no `status.spider` slot present — the legacy path that writes only `resolution`) are left untouched.
56
69
 
57
70
  ### `show(id): Promise<RigDoc>`
58
71
 
@@ -1 +1 @@
1
- {"version":3,"file":"spider.d.ts","sourceRoot":"","sources":["../src/spider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAwD,MAAM,wBAAwB,CAAC;AAQ3G,OAAO,KAAK,EACV,MAAM,EAUN,WAAW,EAKZ,MAAM,YAAY,CAAC;AAmDpB,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACxB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC3C,oFAAoF;IACpF,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9C;AA+CD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAQ1D;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM5D;AA2/BD,wBAAgB,YAAY,IAAI,MAAM,CAwiCrC"}
1
+ {"version":3,"file":"spider.d.ts","sourceRoot":"","sources":["../src/spider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAwD,MAAM,wBAAwB,CAAC;AAQ3G,OAAO,KAAK,EACV,MAAM,EAUN,WAAW,EAOZ,MAAM,YAAY,CAAC;AAmDpB,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACxB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAC3C,oFAAoF;IACpF,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9C;AAyDD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,MAAM,CAQ1D;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAM5D;AA2/BD,wBAAgB,YAAY,IAAI,MAAM,CAy0CrC"}
package/dist/spider.js CHANGED
@@ -32,6 +32,15 @@ import { crawlOneTool, crawlContinualTool, rigShowTool, rigListTool, rigForWritT
32
32
  import { spiderRoutes } from "./oculus-routes.js";
33
33
  import { interpolateTemplate, extractExpressions, resolveDotPath, SKIP, } from "./template.js";
34
34
  // ── Helpers ────────────────────────────────────────────────────────────
35
+ /**
36
+ * Produce the two-segment short id form of a writ id for human-readable
37
+ * resolution text (e.g. `w-abc123`). Mirrors the canonical implementation
38
+ * in `ratchet/src/tools/click-tree.ts`; replicated inline here to avoid a
39
+ * Spider → Ratchet dependency for a three-line utility.
40
+ */
41
+ function shortId(id) {
42
+ return id.split('-').slice(0, 2).join('-');
43
+ }
35
44
  /**
36
45
  * Check whether a value is JSON-serializable.
37
46
  * Non-serializable yields cause engine failure — the Stacks cannot store them.
@@ -1485,6 +1494,204 @@ export function createSpider() {
1485
1494
  *
1486
1495
  * Find the oldest open writ with no existing rig. Create a rig for it.
1487
1496
  */
1497
+ // ── spider.follows gate evaluation ──────────────────────────────────
1498
+ //
1499
+ // These are the terminal phase-categories the gate cares about. A blocker
1500
+ // in a terminal-success state (completed or cancelled) releases its edge;
1501
+ // a blocker that has `failed` cascades the dependent into stuck; any
1502
+ // non-terminal phase (new/open/stuck) holds the gate.
1503
+ const TERMINAL_SUCCESS_PHASES = new Set(['completed', 'cancelled']);
1504
+ const TERMINAL_PHASES = new Set(['completed', 'failed', 'cancelled']);
1505
+ /** Fetch outbound `spider.follows` target ids for a given writ id. */
1506
+ async function getSpiderFollowsTargets(writId) {
1507
+ const { outbound } = await clerk.links(writId);
1508
+ return outbound
1509
+ .filter((l) => l.kind === 'spider.follows')
1510
+ .map((l) => l.targetId);
1511
+ }
1512
+ /**
1513
+ * Walk the candidate's outbound `spider.follows` edges.
1514
+ *
1515
+ * Decisions about the candidate's gate state come from the **direct**
1516
+ * outbound edges only (per D14). Cycle detection, however, runs an
1517
+ * iterative DFS over the full transitive closure so back-edges that
1518
+ * only reveal themselves two or more hops out still fire.
1519
+ *
1520
+ * Per D3: `visiting` (in-stack) distinguishes back-edges from forward
1521
+ * re-visits. `visited` (fully explored) keeps diamonds from being
1522
+ * mistaken for cycles — a node reachable through two paths but never
1523
+ * appearing on the current DFS stack is a diamond, not a cycle.
1524
+ */
1525
+ async function evaluateGate(candidateId) {
1526
+ const directTargets = await getSpiderFollowsTargets(candidateId);
1527
+ // Fast path: no outbound spider.follows at all → nothing to gate on.
1528
+ if (directTargets.length === 0)
1529
+ return { kind: 'ready' };
1530
+ // First: run a DFS for cycle detection over the transitive closure.
1531
+ const visiting = new Set();
1532
+ const visited = new Set();
1533
+ // Parent tracker: at the time we add `child` to visiting, we record
1534
+ // parent[child] = current node. We use it to reconstruct the cycle
1535
+ // path when a back-edge fires.
1536
+ const parent = new Map();
1537
+ const stack = [{ kind: 'enter', id: candidateId, parent: null }];
1538
+ while (stack.length > 0) {
1539
+ const frame = stack.pop();
1540
+ if (frame.kind === 'leave') {
1541
+ visiting.delete(frame.id);
1542
+ visited.add(frame.id);
1543
+ continue;
1544
+ }
1545
+ // enter
1546
+ if (visited.has(frame.id))
1547
+ continue; // diamond — not a cycle
1548
+ if (visiting.has(frame.id)) {
1549
+ // Back-edge: frame.id is already on the current DFS stack. Walk
1550
+ // up via `parent` from the discovering node (frame.parent) until
1551
+ // we hit frame.id, then include frame.id itself. These are the
1552
+ // cycle members.
1553
+ const members = [];
1554
+ let cursor = frame.parent;
1555
+ while (cursor !== null && cursor !== frame.id) {
1556
+ members.push(cursor);
1557
+ cursor = parent.get(cursor) ?? null;
1558
+ }
1559
+ members.push(frame.id);
1560
+ return { kind: 'cycle', members };
1561
+ }
1562
+ visiting.add(frame.id);
1563
+ parent.set(frame.id, frame.parent);
1564
+ // Schedule leave *before* enqueuing children so they run first (LIFO).
1565
+ stack.push({ kind: 'leave', id: frame.id, parent: frame.parent });
1566
+ const outboundTargets = await getSpiderFollowsTargets(frame.id);
1567
+ for (const t of outboundTargets) {
1568
+ stack.push({ kind: 'enter', id: t, parent: frame.id });
1569
+ }
1570
+ }
1571
+ // No cycle — classify the direct blockers by target phase.
1572
+ const failedBlockers = [];
1573
+ const nonTerminalBlockers = [];
1574
+ for (const targetId of directTargets) {
1575
+ const target = await writsBook.get(targetId);
1576
+ if (!target) {
1577
+ // Dangling reference — treat as non-terminal hold. The link was
1578
+ // created against a live target, so a missing target is an
1579
+ // operator/data-integrity condition better surfaced as "still
1580
+ // gated" than as "ready".
1581
+ nonTerminalBlockers.push(targetId);
1582
+ continue;
1583
+ }
1584
+ const phase = target.phase;
1585
+ if (phase === 'failed') {
1586
+ failedBlockers.push(targetId);
1587
+ }
1588
+ else if (TERMINAL_SUCCESS_PHASES.has(phase)) {
1589
+ // Released — no-op.
1590
+ }
1591
+ else {
1592
+ nonTerminalBlockers.push(targetId);
1593
+ }
1594
+ }
1595
+ if (failedBlockers.length > 0) {
1596
+ return { kind: 'failed-blocker', blockerIds: failedBlockers };
1597
+ }
1598
+ if (nonTerminalBlockers.length > 0) {
1599
+ return { kind: 'gated', blockerIds: nonTerminalBlockers };
1600
+ }
1601
+ return { kind: 'ready' };
1602
+ }
1603
+ /**
1604
+ * Stick a writ because its gate evaluation produced a stuck condition.
1605
+ * Writes both the phase transition (via Clerk) and the provenance slot
1606
+ * (via setWritStatus) — the load-bearing `status.spider.stuckCause` is
1607
+ * the signal `autoUnstick` uses to re-evaluate later.
1608
+ */
1609
+ async function stuckFromGate(writId, cause, blockerIds, resolution) {
1610
+ await clerk.transition(writId, 'stuck', { resolution });
1611
+ const status = {
1612
+ stuckCause: cause,
1613
+ blockerIds,
1614
+ observedAt: new Date().toISOString(),
1615
+ };
1616
+ await clerk.setWritStatus(writId, 'spider', status);
1617
+ }
1618
+ /**
1619
+ * Phase: autoUnstick.
1620
+ *
1621
+ * Re-evaluate every writ Spider previously stuck through the gating
1622
+ * path. Writs without `status.spider?.stuckCause` are skipped entirely
1623
+ * — the engine-cascade stuck path never writes that slot, so this is
1624
+ * how we avoid touching those (D20 / D5).
1625
+ *
1626
+ * Release conditions (D15):
1627
+ * - `failed-blocker`: every recorded blocker id is now in a
1628
+ * terminal-success phase. (If any are still failed or
1629
+ * non-terminal, keep the writ stuck.)
1630
+ * - `cycle`: any recorded cycle member has moved out of the cycle
1631
+ * (non-open phase, or no longer has the originally-observed
1632
+ * outbound `spider.follows` edge — the simplest proxy is that at
1633
+ * least one member is no longer in `open`). The next poll's
1634
+ * trySpawn pass will re-evaluate the gate; if the cycle remains,
1635
+ * it re-sticks.
1636
+ *
1637
+ * Returns the first successful unstick as a CrawlResult so callers
1638
+ * can observe progress; returns null when nothing was unsticked.
1639
+ */
1640
+ async function autoUnstick() {
1641
+ const stuckWrits = await writsBook.find({
1642
+ where: [['phase', '=', 'stuck']],
1643
+ });
1644
+ for (const writ of stuckWrits) {
1645
+ const spiderStatus = writ.status?.spider;
1646
+ const cause = spiderStatus?.stuckCause;
1647
+ if (!cause)
1648
+ continue; // engine-cascade stuck or operator stuck — not ours
1649
+ const blockerIds = spiderStatus?.blockerIds ?? [];
1650
+ if (cause === 'failed-blocker') {
1651
+ // Every blocker must now be in a terminal-success state.
1652
+ let allResolved = true;
1653
+ for (const blockerId of blockerIds) {
1654
+ const blocker = await writsBook.get(blockerId);
1655
+ if (!blocker) {
1656
+ // Blocker disappeared — treat as resolved (no longer a gate).
1657
+ continue;
1658
+ }
1659
+ if (!TERMINAL_SUCCESS_PHASES.has(blocker.phase)) {
1660
+ allResolved = false;
1661
+ break;
1662
+ }
1663
+ }
1664
+ if (!allResolved)
1665
+ continue;
1666
+ }
1667
+ else if (cause === 'cycle') {
1668
+ // Release if any cycle member has moved out of `open` — the
1669
+ // cycle can only persist while every member is still open.
1670
+ let brokenByAnyMember = false;
1671
+ for (const memberId of blockerIds) {
1672
+ const member = await writsBook.get(memberId);
1673
+ if (!member) {
1674
+ brokenByAnyMember = true;
1675
+ break;
1676
+ }
1677
+ if (member.id === writ.id)
1678
+ continue; // self is currently stuck
1679
+ if (member.phase !== 'open' && member.phase !== 'stuck') {
1680
+ brokenByAnyMember = true;
1681
+ break;
1682
+ }
1683
+ }
1684
+ if (!brokenByAnyMember)
1685
+ continue;
1686
+ }
1687
+ // Release: stuck → open. Clear the spider sub-slot (so future
1688
+ // `autoUnstick` passes don't revisit this writ).
1689
+ await clerk.transition(writ.id, 'open');
1690
+ await clerk.setWritStatus(writ.id, 'spider', {});
1691
+ return { action: 'writ-unstuck', writId: writ.id };
1692
+ }
1693
+ return null;
1694
+ }
1488
1695
  async function trySpawn() {
1489
1696
  // Throttle: do not spawn new rigs if system-wide engine limit is reached.
1490
1697
  // Spawned rigs would just sit with their first engine in pending, cluttering the rig list.
@@ -1517,6 +1724,41 @@ export function createSpider() {
1517
1724
  });
1518
1725
  if (existing.length > 0)
1519
1726
  continue;
1727
+ // Gate on outbound spider.follows links before dispatch. Evaluation
1728
+ // produces one of four outcomes — see `evaluateGate`. The walk also
1729
+ // runs cycle detection; back-edges surface as a `cycle` outcome.
1730
+ const gate = await evaluateGate(writ.id);
1731
+ if (gate.kind === 'gated') {
1732
+ // Non-terminal blockers — hold dispatch. Nothing is written to
1733
+ // status (D1: the gate-but-not-stuck state is not persisted). We
1734
+ // continue to the next candidate so a later, unblocked writ can
1735
+ // still dispatch this tick.
1736
+ return { action: 'gated', writId: writ.id, blockerIds: gate.blockerIds };
1737
+ }
1738
+ if (gate.kind === 'failed-blocker') {
1739
+ const shortIds = gate.blockerIds.map(shortId);
1740
+ const resolution = gate.blockerIds.length === 1
1741
+ ? `Blocked by failed dependency: ${shortIds[0]}`
1742
+ : `Blocked by failed dependencies: ${shortIds.join(', ')}`;
1743
+ await stuckFromGate(writ.id, 'failed-blocker', gate.blockerIds, resolution);
1744
+ return { action: 'gated', writId: writ.id, blockerIds: gate.blockerIds };
1745
+ }
1746
+ if (gate.kind === 'cycle') {
1747
+ // Stick every member of the cycle with stuckCause='cycle'. Only
1748
+ // members still in `open` are transitioned — a member already
1749
+ // in another phase (e.g. stuck from a prior detection) just gets
1750
+ // its provenance slot rewritten with the fresh observedAt.
1751
+ for (const memberId of gate.members) {
1752
+ const member = await writsBook.get(memberId);
1753
+ if (!member)
1754
+ continue;
1755
+ if (member.phase === 'open') {
1756
+ await stuckFromGate(memberId, 'cycle', gate.members, 'Cycle detected in spider.follows graph');
1757
+ }
1758
+ }
1759
+ return { action: 'gated', writId: writ.id, blockerIds: gate.members };
1760
+ }
1761
+ // gate.kind === 'ready' — fall through to dispatch.
1520
1762
  // The query-level type filter above guarantees this lookup succeeds.
1521
1763
  // A null here would mean the registry's mappings diverged from what
1522
1764
  // listTemplateMappings() returned mid-crawl — an invariant violation.
@@ -1558,6 +1800,13 @@ export function createSpider() {
1558
1800
  const ran = await tryRun();
1559
1801
  if (ran)
1560
1802
  return ran;
1803
+ // Before trySpawn: re-evaluate writs Spider previously stuck via the
1804
+ // gating path. `autoUnstick` skips writs without
1805
+ // `status.spider?.stuckCause` so the engine-cascade stuck path stays
1806
+ // untouched (D6).
1807
+ const unstuck = await autoUnstick();
1808
+ if (unstuck)
1809
+ return unstuck;
1561
1810
  const spawned = await trySpawn();
1562
1811
  if (spawned)
1563
1812
  return spawned;
@@ -1698,6 +1947,12 @@ export function createSpider() {
1698
1947
  indexes: ['status', 'rigId', 'engineId', 'createdAt', ['rigId', 'engineId', 'status']],
1699
1948
  },
1700
1949
  },
1950
+ linkKinds: [
1951
+ {
1952
+ id: 'spider.follows',
1953
+ description: 'The source writ is a precedence-successor of the target: source cannot be dispatched until the target reaches a terminal state. Consumers define their own policy for what happens on each terminal state.',
1954
+ },
1955
+ ],
1701
1956
  engines: {
1702
1957
  'anima-session': animaSessionEngine,
1703
1958
  draft: draftEngine,