@mclawnet/swarm 0.1.13 → 0.1.14
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/__tests__/always-on-activity-reader.test.d.ts +2 -0
- package/dist/__tests__/always-on-activity-reader.test.d.ts.map +1 -0
- package/dist/__tests__/always-on-activity-reader.test.js +193 -0
- package/dist/__tests__/always-on-activity-reader.test.js.map +1 -0
- package/dist/__tests__/always-on-config.test.d.ts +2 -0
- package/dist/__tests__/always-on-config.test.d.ts.map +1 -0
- package/dist/__tests__/always-on-config.test.js +285 -0
- package/dist/__tests__/always-on-config.test.js.map +1 -0
- package/dist/__tests__/always-on-manager.test.d.ts +2 -0
- package/dist/__tests__/always-on-manager.test.d.ts.map +1 -0
- package/dist/__tests__/always-on-manager.test.js +797 -0
- package/dist/__tests__/always-on-manager.test.js.map +1 -0
- package/dist/__tests__/always-on-parity.test.d.ts +2 -0
- package/dist/__tests__/always-on-parity.test.d.ts.map +1 -0
- package/dist/__tests__/always-on-parity.test.js +20 -0
- package/dist/__tests__/always-on-parity.test.js.map +1 -0
- package/dist/__tests__/cascade-picker.test.d.ts +2 -0
- package/dist/__tests__/cascade-picker.test.d.ts.map +1 -0
- package/dist/__tests__/cascade-picker.test.js +122 -0
- package/dist/__tests__/cascade-picker.test.js.map +1 -0
- package/dist/__tests__/coordinator-shipment.test.d.ts +2 -0
- package/dist/__tests__/coordinator-shipment.test.d.ts.map +1 -0
- package/dist/__tests__/coordinator-shipment.test.js +280 -0
- package/dist/__tests__/coordinator-shipment.test.js.map +1 -0
- package/dist/__tests__/coordinator-workspace-recover.test.d.ts +2 -0
- package/dist/__tests__/coordinator-workspace-recover.test.d.ts.map +1 -0
- package/dist/__tests__/coordinator-workspace-recover.test.js +140 -0
- package/dist/__tests__/coordinator-workspace-recover.test.js.map +1 -0
- package/dist/__tests__/coordinator-workspace.test.d.ts +2 -0
- package/dist/__tests__/coordinator-workspace.test.d.ts.map +1 -0
- package/dist/__tests__/coordinator-workspace.test.js +135 -0
- package/dist/__tests__/coordinator-workspace.test.js.map +1 -0
- package/dist/__tests__/default-runner-epipe.test.d.ts +2 -0
- package/dist/__tests__/default-runner-epipe.test.d.ts.map +1 -0
- package/dist/__tests__/default-runner-epipe.test.js +43 -0
- package/dist/__tests__/default-runner-epipe.test.js.map +1 -0
- package/dist/__tests__/discovery-scheduler.test.d.ts +2 -0
- package/dist/__tests__/discovery-scheduler.test.d.ts.map +1 -0
- package/dist/__tests__/discovery-scheduler.test.js +367 -0
- package/dist/__tests__/discovery-scheduler.test.js.map +1 -0
- package/dist/__tests__/env-forward-e2e.test.d.ts +2 -0
- package/dist/__tests__/env-forward-e2e.test.d.ts.map +1 -0
- package/dist/__tests__/env-forward-e2e.test.js +57 -0
- package/dist/__tests__/env-forward-e2e.test.js.map +1 -0
- package/dist/__tests__/gh-pr-creator.test.d.ts +2 -0
- package/dist/__tests__/gh-pr-creator.test.d.ts.map +1 -0
- package/dist/__tests__/gh-pr-creator.test.js +107 -0
- package/dist/__tests__/gh-pr-creator.test.js.map +1 -0
- package/dist/__tests__/git-worktree-provider.test.d.ts +2 -0
- package/dist/__tests__/git-worktree-provider.test.d.ts.map +1 -0
- package/dist/__tests__/git-worktree-provider.test.js +98 -0
- package/dist/__tests__/git-worktree-provider.test.js.map +1 -0
- package/dist/__tests__/gitignore-check.test.d.ts +2 -0
- package/dist/__tests__/gitignore-check.test.d.ts.map +1 -0
- package/dist/__tests__/gitignore-check.test.js +39 -0
- package/dist/__tests__/gitignore-check.test.js.map +1 -0
- package/dist/__tests__/idea-research-source.test.d.ts +2 -0
- package/dist/__tests__/idea-research-source.test.d.ts.map +1 -0
- package/dist/__tests__/idea-research-source.test.js +425 -0
- package/dist/__tests__/idea-research-source.test.js.map +1 -0
- package/dist/__tests__/idea-todo-source.test.d.ts +2 -0
- package/dist/__tests__/idea-todo-source.test.d.ts.map +1 -0
- package/dist/__tests__/idea-todo-source.test.js +258 -0
- package/dist/__tests__/idea-todo-source.test.js.map +1 -0
- package/dist/__tests__/introspection-dedupe.test.d.ts +2 -0
- package/dist/__tests__/introspection-dedupe.test.d.ts.map +1 -0
- package/dist/__tests__/introspection-dedupe.test.js +484 -0
- package/dist/__tests__/introspection-dedupe.test.js.map +1 -0
- package/dist/__tests__/introspection-source.test.d.ts +2 -0
- package/dist/__tests__/introspection-source.test.d.ts.map +1 -0
- package/dist/__tests__/introspection-source.test.js +1051 -0
- package/dist/__tests__/introspection-source.test.js.map +1 -0
- package/dist/__tests__/migration-roles.test.js +1 -22
- package/dist/__tests__/migration-roles.test.js.map +1 -1
- package/dist/__tests__/reconcile-researching.test.d.ts +2 -0
- package/dist/__tests__/reconcile-researching.test.d.ts.map +1 -0
- package/dist/__tests__/reconcile-researching.test.js +224 -0
- package/dist/__tests__/reconcile-researching.test.js.map +1 -0
- package/dist/__tests__/role-loader-preamble-all.test.js +3 -1
- package/dist/__tests__/role-loader-preamble-all.test.js.map +1 -1
- package/dist/__tests__/role-loader.test.js +95 -0
- package/dist/__tests__/role-loader.test.js.map +1 -1
- package/dist/__tests__/role-prompt-no-legacy-protocol.test.js +3 -1
- package/dist/__tests__/role-prompt-no-legacy-protocol.test.js.map +1 -1
- package/dist/__tests__/secret-scrub.test.d.ts +2 -0
- package/dist/__tests__/secret-scrub.test.d.ts.map +1 -0
- package/dist/__tests__/secret-scrub.test.js +55 -0
- package/dist/__tests__/secret-scrub.test.js.map +1 -0
- package/dist/__tests__/shipment-actions.test.d.ts +2 -0
- package/dist/__tests__/shipment-actions.test.d.ts.map +1 -0
- package/dist/__tests__/shipment-actions.test.js +378 -0
- package/dist/__tests__/shipment-actions.test.js.map +1 -0
- package/dist/__tests__/shipment-persistence.test.d.ts +2 -0
- package/dist/__tests__/shipment-persistence.test.d.ts.map +1 -0
- package/dist/__tests__/shipment-persistence.test.js +120 -0
- package/dist/__tests__/shipment-persistence.test.js.map +1 -0
- package/dist/__tests__/shipment-pipeline.test.d.ts +2 -0
- package/dist/__tests__/shipment-pipeline.test.d.ts.map +1 -0
- package/dist/__tests__/shipment-pipeline.test.js +392 -0
- package/dist/__tests__/shipment-pipeline.test.js.map +1 -0
- package/dist/__tests__/shipment-report.test.d.ts +2 -0
- package/dist/__tests__/shipment-report.test.d.ts.map +1 -0
- package/dist/__tests__/shipment-report.test.js +78 -0
- package/dist/__tests__/shipment-report.test.js.map +1 -0
- package/dist/__tests__/shipment-stdin-integration.test.d.ts +2 -0
- package/dist/__tests__/shipment-stdin-integration.test.d.ts.map +1 -0
- package/dist/__tests__/shipment-stdin-integration.test.js +49 -0
- package/dist/__tests__/shipment-stdin-integration.test.js.map +1 -0
- package/dist/__tests__/shipment-type-parity.test.d.ts +2 -0
- package/dist/__tests__/shipment-type-parity.test.d.ts.map +1 -0
- package/dist/__tests__/shipment-type-parity.test.js +10 -0
- package/dist/__tests__/shipment-type-parity.test.js.map +1 -0
- package/dist/__tests__/snapshot-copy-provider.test.d.ts +2 -0
- package/dist/__tests__/snapshot-copy-provider.test.d.ts.map +1 -0
- package/dist/__tests__/snapshot-copy-provider.test.js +88 -0
- package/dist/__tests__/snapshot-copy-provider.test.js.map +1 -0
- package/dist/__tests__/swarm-coordinator-backend.test.js +153 -0
- package/dist/__tests__/swarm-coordinator-backend.test.js.map +1 -1
- package/dist/__tests__/swarm-coordinator-complete-intercept.test.d.ts +2 -0
- package/dist/__tests__/swarm-coordinator-complete-intercept.test.d.ts.map +1 -0
- package/dist/__tests__/swarm-coordinator-complete-intercept.test.js +111 -0
- package/dist/__tests__/swarm-coordinator-complete-intercept.test.js.map +1 -0
- package/dist/__tests__/task-store-source.test.d.ts +2 -0
- package/dist/__tests__/task-store-source.test.d.ts.map +1 -0
- package/dist/__tests__/task-store-source.test.js +56 -0
- package/dist/__tests__/task-store-source.test.js.map +1 -0
- package/dist/__tests__/transport-detect.test.d.ts +2 -0
- package/dist/__tests__/transport-detect.test.d.ts.map +1 -0
- package/dist/__tests__/transport-detect.test.js +92 -0
- package/dist/__tests__/transport-detect.test.js.map +1 -0
- package/dist/__tests__/workcycle-runner-cascade.test.d.ts +2 -0
- package/dist/__tests__/workcycle-runner-cascade.test.d.ts.map +1 -0
- package/dist/__tests__/workcycle-runner-cascade.test.js +203 -0
- package/dist/__tests__/workcycle-runner-cascade.test.js.map +1 -0
- package/dist/__tests__/workcycle-runner.test.d.ts +2 -0
- package/dist/__tests__/workcycle-runner.test.d.ts.map +1 -0
- package/dist/__tests__/workcycle-runner.test.js +369 -0
- package/dist/__tests__/workcycle-runner.test.js.map +1 -0
- package/dist/__tests__/workspace-diff.test.d.ts +2 -0
- package/dist/__tests__/workspace-diff.test.d.ts.map +1 -0
- package/dist/__tests__/workspace-diff.test.js +62 -0
- package/dist/__tests__/workspace-diff.test.js.map +1 -0
- package/dist/__tests__/workspace-manager.test.d.ts +2 -0
- package/dist/__tests__/workspace-manager.test.d.ts.map +1 -0
- package/dist/__tests__/workspace-manager.test.js +120 -0
- package/dist/__tests__/workspace-manager.test.js.map +1 -0
- package/dist/__tests__/workspace-types.test.d.ts +2 -0
- package/dist/__tests__/workspace-types.test.d.ts.map +1 -0
- package/dist/__tests__/workspace-types.test.js +37 -0
- package/dist/__tests__/workspace-types.test.js.map +1 -0
- package/dist/__tests__/worktree-gc.test.d.ts +2 -0
- package/dist/__tests__/worktree-gc.test.d.ts.map +1 -0
- package/dist/__tests__/worktree-gc.test.js +183 -0
- package/dist/__tests__/worktree-gc.test.js.map +1 -0
- package/dist/always-on/activity-reader.d.ts +27 -0
- package/dist/always-on/activity-reader.d.ts.map +1 -0
- package/dist/always-on/activity-reader.js +95 -0
- package/dist/always-on/activity-reader.js.map +1 -0
- package/dist/always-on/always-on-manager.d.ts +170 -0
- package/dist/always-on/always-on-manager.d.ts.map +1 -0
- package/dist/always-on/always-on-manager.js +538 -0
- package/dist/always-on/always-on-manager.js.map +1 -0
- package/dist/always-on/config.d.ts +141 -0
- package/dist/always-on/config.d.ts.map +1 -0
- package/dist/always-on/config.js +324 -0
- package/dist/always-on/config.js.map +1 -0
- package/dist/always-on/discovery-scheduler.d.ts +60 -0
- package/dist/always-on/discovery-scheduler.d.ts.map +1 -0
- package/dist/always-on/discovery-scheduler.js +287 -0
- package/dist/always-on/discovery-scheduler.js.map +1 -0
- package/dist/always-on/ideas-client.d.ts +23 -0
- package/dist/always-on/ideas-client.d.ts.map +1 -0
- package/dist/always-on/ideas-client.js +13 -0
- package/dist/always-on/ideas-client.js.map +1 -0
- package/dist/always-on/reconcile-researching.d.ts +42 -0
- package/dist/always-on/reconcile-researching.d.ts.map +1 -0
- package/dist/always-on/reconcile-researching.js +133 -0
- package/dist/always-on/reconcile-researching.js.map +1 -0
- package/dist/always-on/task-sources/cascade-picker.d.ts +42 -0
- package/dist/always-on/task-sources/cascade-picker.d.ts.map +1 -0
- package/dist/always-on/task-sources/cascade-picker.js +65 -0
- package/dist/always-on/task-sources/cascade-picker.js.map +1 -0
- package/dist/always-on/task-sources/idea-dedupe.d.ts +62 -0
- package/dist/always-on/task-sources/idea-dedupe.d.ts.map +1 -0
- package/dist/always-on/task-sources/idea-dedupe.js +130 -0
- package/dist/always-on/task-sources/idea-dedupe.js.map +1 -0
- package/dist/always-on/task-sources/idea-research-source.d.ts +46 -0
- package/dist/always-on/task-sources/idea-research-source.d.ts.map +1 -0
- package/dist/always-on/task-sources/idea-research-source.js +308 -0
- package/dist/always-on/task-sources/idea-research-source.js.map +1 -0
- package/dist/always-on/task-sources/idea-sort.d.ts +3 -0
- package/dist/always-on/task-sources/idea-sort.d.ts.map +1 -0
- package/dist/always-on/task-sources/idea-sort.js +25 -0
- package/dist/always-on/task-sources/idea-sort.js.map +1 -0
- package/dist/always-on/task-sources/idea-todo-source.d.ts +48 -0
- package/dist/always-on/task-sources/idea-todo-source.d.ts.map +1 -0
- package/dist/always-on/task-sources/idea-todo-source.js +226 -0
- package/dist/always-on/task-sources/idea-todo-source.js.map +1 -0
- package/dist/always-on/task-sources/introspection-source.d.ts +101 -0
- package/dist/always-on/task-sources/introspection-source.d.ts.map +1 -0
- package/dist/always-on/task-sources/introspection-source.js +695 -0
- package/dist/always-on/task-sources/introspection-source.js.map +1 -0
- package/dist/always-on/task-sources/task-store-source.d.ts +15 -0
- package/dist/always-on/task-sources/task-store-source.d.ts.map +1 -0
- package/dist/always-on/task-sources/task-store-source.js +59 -0
- package/dist/always-on/task-sources/task-store-source.js.map +1 -0
- package/dist/always-on/task-sources/types.d.ts +108 -0
- package/dist/always-on/task-sources/types.d.ts.map +1 -0
- package/dist/always-on/task-sources/types.js +13 -0
- package/dist/always-on/task-sources/types.js.map +1 -0
- package/dist/always-on/types.d.ts +76 -0
- package/dist/always-on/types.d.ts.map +1 -0
- package/dist/always-on/types.js +17 -0
- package/dist/always-on/types.js.map +1 -0
- package/dist/always-on/workcycle-runner.d.ts +115 -0
- package/dist/always-on/workcycle-runner.d.ts.map +1 -0
- package/dist/always-on/workcycle-runner.js +285 -0
- package/dist/always-on/workcycle-runner.js.map +1 -0
- package/dist/always-on/worktree-gc.d.ts +41 -0
- package/dist/always-on/worktree-gc.d.ts.map +1 -0
- package/dist/always-on/worktree-gc.js +167 -0
- package/dist/always-on/worktree-gc.js.map +1 -0
- package/dist/index.d.ts +26 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -1
- package/dist/index.js.map +1 -1
- package/dist/persistence.d.ts +37 -1
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js +48 -0
- package/dist/persistence.js.map +1 -1
- package/dist/retrospective.d.ts.map +1 -1
- package/dist/retrospective.js +6 -0
- package/dist/retrospective.js.map +1 -1
- package/dist/roles/role-loader.d.ts +1 -1
- package/dist/roles/role-loader.d.ts.map +1 -1
- package/dist/roles/role-loader.js +18 -0
- package/dist/roles/role-loader.js.map +1 -1
- package/dist/roles/types.d.ts +12 -0
- package/dist/roles/types.d.ts.map +1 -1
- package/dist/shipment/gh-pr-creator.d.ts +28 -0
- package/dist/shipment/gh-pr-creator.d.ts.map +1 -0
- package/dist/shipment/gh-pr-creator.js +80 -0
- package/dist/shipment/gh-pr-creator.js.map +1 -0
- package/dist/shipment/report.d.ts +27 -0
- package/dist/shipment/report.d.ts.map +1 -0
- package/dist/shipment/report.js +41 -0
- package/dist/shipment/report.js.map +1 -0
- package/dist/shipment/secret-scrub.d.ts +12 -0
- package/dist/shipment/secret-scrub.d.ts.map +1 -0
- package/dist/shipment/secret-scrub.js +30 -0
- package/dist/shipment/secret-scrub.js.map +1 -0
- package/dist/shipment/shipment-actions.d.ts +85 -0
- package/dist/shipment/shipment-actions.d.ts.map +1 -0
- package/dist/shipment/shipment-actions.js +190 -0
- package/dist/shipment/shipment-actions.js.map +1 -0
- package/dist/shipment/shipment-pipeline.d.ts +48 -0
- package/dist/shipment/shipment-pipeline.d.ts.map +1 -0
- package/dist/shipment/shipment-pipeline.js +256 -0
- package/dist/shipment/shipment-pipeline.js.map +1 -0
- package/dist/shipment/transport-detect.d.ts +16 -0
- package/dist/shipment/transport-detect.d.ts.map +1 -0
- package/dist/shipment/transport-detect.js +54 -0
- package/dist/shipment/transport-detect.js.map +1 -0
- package/dist/shipment/workspace-diff.d.ts +39 -0
- package/dist/shipment/workspace-diff.d.ts.map +1 -0
- package/dist/shipment/workspace-diff.js +64 -0
- package/dist/shipment/workspace-diff.js.map +1 -0
- package/dist/swarm-coordinator.d.ts +20 -1
- package/dist/swarm-coordinator.d.ts.map +1 -1
- package/dist/swarm-coordinator.js +193 -10
- package/dist/swarm-coordinator.js.map +1 -1
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/workspace/git-worktree-provider.d.ts +11 -0
- package/dist/workspace/git-worktree-provider.d.ts.map +1 -0
- package/dist/workspace/git-worktree-provider.js +123 -0
- package/dist/workspace/git-worktree-provider.js.map +1 -0
- package/dist/workspace/gitignore-check.d.ts +10 -0
- package/dist/workspace/gitignore-check.d.ts.map +1 -0
- package/dist/workspace/gitignore-check.js +25 -0
- package/dist/workspace/gitignore-check.js.map +1 -0
- package/dist/workspace/index.d.ts +5 -0
- package/dist/workspace/index.d.ts.map +1 -0
- package/dist/workspace/index.js +5 -0
- package/dist/workspace/index.js.map +1 -0
- package/dist/workspace/snapshot-copy-provider.d.ts +11 -0
- package/dist/workspace/snapshot-copy-provider.d.ts.map +1 -0
- package/dist/workspace/snapshot-copy-provider.js +66 -0
- package/dist/workspace/snapshot-copy-provider.js.map +1 -0
- package/dist/workspace/types.d.ts +36 -0
- package/dist/workspace/types.d.ts.map +1 -0
- package/dist/workspace/types.js +2 -0
- package/dist/workspace/types.js.map +1 -0
- package/dist/workspace/workspace-manager.d.ts +30 -0
- package/dist/workspace/workspace-manager.d.ts.map +1 -0
- package/dist/workspace/workspace-manager.js +104 -0
- package/dist/workspace/workspace-manager.js.map +1 -0
- package/package.json +4 -4
- package/roles/queen.md +1 -0
- package/templates/introspection.md +64 -0
- package/templates/research-only.md +58 -0
- package/roles/preset-analyst-simons.md +0 -39
- package/roles/preset-architect-knuth.md +0 -39
- package/roles/preset-designer-norman.md +0 -39
- package/roles/preset-designer.md +0 -39
- package/roles/preset-dev-carmack.md +0 -39
- package/roles/preset-dev-gosling.md +0 -39
- package/roles/preset-developer.md +0 -52
- package/roles/preset-manager-grove.md +0 -39
- package/roles/preset-manager-musk.md +0 -39
- package/roles/preset-pm.md +0 -78
- package/roles/preset-researcher-feynman.md +0 -39
- package/roles/preset-reviewer.md +0 -46
- package/roles/preset-strategist-buffett.md +0 -39
- package/roles/preset-strategist-munger.md +0 -39
- package/roles/preset-strategist-sunzi.md +0 -39
- package/roles/preset-tester-beck.md +0 -40
- package/roles/preset-tester.md +0 -47
- package/roles/preset-writer-orwell.md +0 -39
- package/roles/preset-writer.md +0 -39
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
// M8.2 + M8 hotfix I1/I2 — IntrospectionSource unit tests.
|
|
2
|
+
//
|
|
3
|
+
// Verifies opt-in gate, cooldown gate, self-suppression, fire-and-forget
|
|
4
|
+
// swarm spawn, output parsing on a SECOND pick (ingest cycle), idea POST
|
|
5
|
+
// cap + priority enforcement, hub-outage cooldown rewind (S1), and the
|
|
6
|
+
// pending-swarm bookkeeping.
|
|
7
|
+
//
|
|
8
|
+
// The two-phase pick model: first pick() spawns + persists
|
|
9
|
+
// `pendingIntrospectionSwarmId`; the second pick() observes the pending
|
|
10
|
+
// swarm is terminal, parses the queen's output and POSTs the cohort.
|
|
11
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { encodeCwd } from "@mclawnet/shared";
|
|
16
|
+
import { createIntrospectionSource, parseCandidatesOutput, buildIntrospectionTaskPrompt, INTROSPECTION_SOURCE_ROLE, INTROSPECTION_SOURCE_TAG, SPAWNER_COMPLETE_MTIME_GUARD_MS, SPAWNER_COMPLETE_HARD_TIMEOUT_MS, } from "../always-on/task-sources/introspection-source.js";
|
|
17
|
+
const PROJECT_ROOT = "/proj-introspect";
|
|
18
|
+
const PROJECT_ID = encodeCwd(PROJECT_ROOT);
|
|
19
|
+
function makeIdea(over) {
|
|
20
|
+
return {
|
|
21
|
+
id: "i1",
|
|
22
|
+
userId: "u1",
|
|
23
|
+
projectId: PROJECT_ID,
|
|
24
|
+
title: "title",
|
|
25
|
+
body: "",
|
|
26
|
+
status: "idea",
|
|
27
|
+
priority: "low",
|
|
28
|
+
tags: [],
|
|
29
|
+
sourceSessionId: null,
|
|
30
|
+
sourceMessageId: null,
|
|
31
|
+
sourceSwarmId: null,
|
|
32
|
+
sourceRole: null,
|
|
33
|
+
linkedSessionId: null,
|
|
34
|
+
linkedSwarmTaskId: null,
|
|
35
|
+
researchReports: [],
|
|
36
|
+
researchKind: null,
|
|
37
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
38
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
39
|
+
...over,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function makeClient(hooks = {}) {
|
|
43
|
+
const creates = [];
|
|
44
|
+
const client = {
|
|
45
|
+
creates,
|
|
46
|
+
async list() {
|
|
47
|
+
return hooks.listResults ?? [];
|
|
48
|
+
},
|
|
49
|
+
async get() {
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
async patch(id, partial) {
|
|
53
|
+
return makeIdea({ id, ...partial });
|
|
54
|
+
},
|
|
55
|
+
async create(input) {
|
|
56
|
+
if (hooks.throwOnCreate)
|
|
57
|
+
throw new Error("hub down");
|
|
58
|
+
hooks.onCreate?.(input);
|
|
59
|
+
const created = makeIdea({
|
|
60
|
+
id: `created-${creates.length + 1}`,
|
|
61
|
+
title: input.title,
|
|
62
|
+
body: input.body ?? "",
|
|
63
|
+
priority: input.priority ?? "low",
|
|
64
|
+
status: input.status ?? "idea",
|
|
65
|
+
tags: input.tags ?? [],
|
|
66
|
+
sourceRole: input.sourceRole ?? null,
|
|
67
|
+
sourceSwarmId: input.sourceSwarmId ?? null,
|
|
68
|
+
projectId: input.projectId ?? null,
|
|
69
|
+
});
|
|
70
|
+
creates.push(created);
|
|
71
|
+
return created;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return client;
|
|
75
|
+
}
|
|
76
|
+
function makeCoord(opts = {}) {
|
|
77
|
+
// Preserve null vs undefined distinction: `null` means "gone from registry"
|
|
78
|
+
// (getSwarm returns undefined); omitted means default ("completed").
|
|
79
|
+
let currentStatus = "swarmStatus" in opts ? opts.swarmStatus : "completed";
|
|
80
|
+
const coord = {
|
|
81
|
+
create: opts.createImpl ?? (async () => { }),
|
|
82
|
+
getSwarm: (_id) => currentStatus === null ? undefined : { status: currentStatus },
|
|
83
|
+
destroy: async (id) => {
|
|
84
|
+
opts.onDestroy?.(id);
|
|
85
|
+
// Simulate real coordinator: destroy evicts the swarm from the
|
|
86
|
+
// registry so a subsequent isTerminalSwarm() returns "unknown".
|
|
87
|
+
// Without this the destroy-fallback test could only verify the
|
|
88
|
+
// method was called, not that it actually cleaned up.
|
|
89
|
+
currentStatus = null;
|
|
90
|
+
},
|
|
91
|
+
complete: async (id) => {
|
|
92
|
+
opts.onComplete?.(id);
|
|
93
|
+
if (opts.completeThrows)
|
|
94
|
+
throw new Error("coord.complete failed");
|
|
95
|
+
if (opts.mutateStatusOnComplete) {
|
|
96
|
+
// Mirror real coordinator: complete() ends with runRetroAndCleanup,
|
|
97
|
+
// whose last step is `this.swarms.delete(swarmId)`. After complete,
|
|
98
|
+
// getSwarm returns undefined and isTerminalSwarm reports "unknown",
|
|
99
|
+
// NOT "completed". Setting currentStatus to null reflects this so
|
|
100
|
+
// tests don't assert code paths that production can't reach.
|
|
101
|
+
currentStatus = null;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
fail: async (id) => {
|
|
105
|
+
opts.onFail?.(id);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return coord;
|
|
109
|
+
}
|
|
110
|
+
function makeConfig(over = {}) {
|
|
111
|
+
return {
|
|
112
|
+
mode: "active",
|
|
113
|
+
dailyBudget: 3,
|
|
114
|
+
taskSources: { introspection: true },
|
|
115
|
+
introspectionCooldownHours: 24,
|
|
116
|
+
...over,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// ── parseCandidatesOutput ──────────────────────────────────────────────
|
|
120
|
+
describe("parseCandidatesOutput", () => {
|
|
121
|
+
it("parses a clean JSON-array file", () => {
|
|
122
|
+
const out = parseCandidatesOutput('[{"title":"a","body":"x"}]');
|
|
123
|
+
expect(out).toEqual([{ title: "a", body: "x" }]);
|
|
124
|
+
});
|
|
125
|
+
it("recovers a JSON array that follows narrative text", () => {
|
|
126
|
+
const raw = `the queen thinks:\nhere are some ideas\n[{"title":"hello","body":"world","priority":"medium"}]`;
|
|
127
|
+
const out = parseCandidatesOutput(raw);
|
|
128
|
+
expect(out).toEqual([{ title: "hello", body: "world", priority: "medium" }]);
|
|
129
|
+
});
|
|
130
|
+
it("returns [] on empty / non-array input", () => {
|
|
131
|
+
expect(parseCandidatesOutput("")).toEqual([]);
|
|
132
|
+
expect(parseCandidatesOutput("not json")).toEqual([]);
|
|
133
|
+
expect(parseCandidatesOutput('{"foo":1}')).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
it("drops entries without a title", () => {
|
|
136
|
+
const out = parseCandidatesOutput('[{"body":"oops"},{"title":"keeper"}]');
|
|
137
|
+
expect(out).toEqual([{ title: "keeper", body: "" }]);
|
|
138
|
+
});
|
|
139
|
+
it("uses the last [ in the file when multiple arrays appear", () => {
|
|
140
|
+
const raw = `[1,2,3] then\n[{"title":"newer"}]`;
|
|
141
|
+
const out = parseCandidatesOutput(raw);
|
|
142
|
+
expect(out).toEqual([{ title: "newer", body: "" }]);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe("buildIntrospectionTaskPrompt", () => {
|
|
146
|
+
it("embeds projectRoot and absolute output path", () => {
|
|
147
|
+
const prompt = buildIntrospectionTaskPrompt("/p", "/p/.clawnet/introspection/swarm-1.json");
|
|
148
|
+
expect(prompt).toContain("/p");
|
|
149
|
+
expect(prompt).toContain("/p/.clawnet/introspection/swarm-1.json");
|
|
150
|
+
expect(prompt).toContain("at most 3 items");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
// ── IntrospectionSource gating ─────────────────────────────────────────
|
|
154
|
+
describe("IntrospectionSource gating", () => {
|
|
155
|
+
it("returns null when taskSources.introspection !== true", async () => {
|
|
156
|
+
const cfg = makeConfig({ taskSources: { introspection: false } });
|
|
157
|
+
const source = createIntrospectionSource({
|
|
158
|
+
ideasClient: makeClient(),
|
|
159
|
+
swarmCoordinator: makeCoord(),
|
|
160
|
+
getConfig: () => cfg,
|
|
161
|
+
updateConfig: async (_pr) => { },
|
|
162
|
+
});
|
|
163
|
+
expect(await source.pick(PROJECT_ROOT)).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
it("defaults to off when taskSources entirely absent", async () => {
|
|
166
|
+
const cfg = makeConfig({ taskSources: undefined });
|
|
167
|
+
const created = vi.fn(async () => { });
|
|
168
|
+
const source = createIntrospectionSource({
|
|
169
|
+
ideasClient: makeClient(),
|
|
170
|
+
swarmCoordinator: makeCoord({ createImpl: created }),
|
|
171
|
+
getConfig: () => cfg,
|
|
172
|
+
updateConfig: async (_pr) => { },
|
|
173
|
+
});
|
|
174
|
+
expect(await source.pick(PROJECT_ROOT)).toBeNull();
|
|
175
|
+
expect(created).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
it("returns null when cooldown not elapsed", async () => {
|
|
178
|
+
const cfg = makeConfig({
|
|
179
|
+
lastIntrospectionAt: new Date("2026-06-06T11:00:00Z").toISOString(),
|
|
180
|
+
introspectionCooldownHours: 24,
|
|
181
|
+
});
|
|
182
|
+
const created = vi.fn(async () => { });
|
|
183
|
+
const source = createIntrospectionSource({
|
|
184
|
+
ideasClient: makeClient(),
|
|
185
|
+
swarmCoordinator: makeCoord({ createImpl: created }),
|
|
186
|
+
getConfig: () => cfg,
|
|
187
|
+
updateConfig: async (_pr) => { },
|
|
188
|
+
clock: () => new Date("2026-06-06T12:00:00Z"),
|
|
189
|
+
});
|
|
190
|
+
expect(await source.pick(PROJECT_ROOT)).toBeNull();
|
|
191
|
+
expect(created).not.toHaveBeenCalled();
|
|
192
|
+
});
|
|
193
|
+
it("suppresses next cycle when lastIntrospectionRejected=true is backed by an all-archived cohort", async () => {
|
|
194
|
+
// Setup: prior cycle ran 2 days ago and all its produced ideas are archived.
|
|
195
|
+
const lastAt = new Date("2026-06-06T00:00:00Z").toISOString();
|
|
196
|
+
const archivedCohort = [
|
|
197
|
+
makeIdea({
|
|
198
|
+
id: "i1",
|
|
199
|
+
status: "archived",
|
|
200
|
+
sourceRole: INTROSPECTION_SOURCE_ROLE,
|
|
201
|
+
tags: [INTROSPECTION_SOURCE_TAG],
|
|
202
|
+
createdAt: "2026-06-06T00:01:00Z",
|
|
203
|
+
}),
|
|
204
|
+
];
|
|
205
|
+
const cfg = makeConfig({
|
|
206
|
+
lastIntrospectionAt: lastAt,
|
|
207
|
+
lastIntrospectionRejected: true,
|
|
208
|
+
});
|
|
209
|
+
const created = vi.fn(async () => { });
|
|
210
|
+
const source = createIntrospectionSource({
|
|
211
|
+
ideasClient: makeClient({ listResults: archivedCohort }),
|
|
212
|
+
swarmCoordinator: makeCoord({ createImpl: created }),
|
|
213
|
+
getConfig: () => cfg,
|
|
214
|
+
updateConfig: async (_pr) => { },
|
|
215
|
+
clock: () => new Date("2026-06-08T00:00:00Z"),
|
|
216
|
+
});
|
|
217
|
+
expect(await source.pick(PROJECT_ROOT)).toBeNull();
|
|
218
|
+
expect(created).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
it("clears stale lastIntrospectionRejected when there's no lastIntrospectionAt (defensive)", async () => {
|
|
221
|
+
// The flag without a cooldown breadcrumb is treated as orphaned: clear
|
|
222
|
+
// it and let the cycle proceed. Otherwise a corrupted config could lock
|
|
223
|
+
// introspection forever with no way to recover via the cohort sync.
|
|
224
|
+
const cfg = makeConfig({
|
|
225
|
+
lastIntrospectionRejected: true,
|
|
226
|
+
// No lastIntrospectionAt set.
|
|
227
|
+
});
|
|
228
|
+
const created = vi.fn(async () => { });
|
|
229
|
+
const patches = [];
|
|
230
|
+
const source = createIntrospectionSource({
|
|
231
|
+
ideasClient: makeClient(),
|
|
232
|
+
swarmCoordinator: makeCoord({ createImpl: created }),
|
|
233
|
+
getConfig: () => cfg,
|
|
234
|
+
updateConfig: async (_pr, patch) => {
|
|
235
|
+
patches.push(patch);
|
|
236
|
+
Object.assign(cfg, patch);
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
await source.pick(PROJECT_ROOT);
|
|
240
|
+
expect(patches[0]).toEqual({ lastIntrospectionRejected: null });
|
|
241
|
+
expect(created).toHaveBeenCalledTimes(1);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// ── Fire-and-forget spawn + ingest ─────────────────────────────────────
|
|
245
|
+
describe("IntrospectionSource fire-and-forget happy path", () => {
|
|
246
|
+
let tmpHome;
|
|
247
|
+
let projectRoot;
|
|
248
|
+
let outputPath;
|
|
249
|
+
beforeEach(() => {
|
|
250
|
+
tmpHome = mkdtempSync(join(tmpdir(), "introspect-"));
|
|
251
|
+
projectRoot = tmpHome; // use the temp dir as projectRoot so writes work
|
|
252
|
+
outputPath = join(projectRoot, ".clawnet", "introspection", "swarm-x.json");
|
|
253
|
+
});
|
|
254
|
+
afterEach(() => {
|
|
255
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
256
|
+
});
|
|
257
|
+
it("spawn cycle: pick() persists pendingIntrospectionSwarmId and returns null WITHOUT awaiting swarm", async () => {
|
|
258
|
+
// No output file written yet → if pick() were awaiting the swarm we'd
|
|
259
|
+
// still see zero POSTs anyway, but we ALSO want to see the pending
|
|
260
|
+
// breadcrumb persisted as proof we didn't block on the swarm.
|
|
261
|
+
const client = makeClient();
|
|
262
|
+
// CI tsc inferred mock.calls[0] as [] (no args) and tripped TS2493 on
|
|
263
|
+
// index access below. Give the spy an explicit signature so calls[0] is
|
|
264
|
+
// [swarmId, opts].
|
|
265
|
+
const created = vi.fn(async (_swarmId, _opts) => { });
|
|
266
|
+
const patches = [];
|
|
267
|
+
const cfg = makeConfig();
|
|
268
|
+
const source = createIntrospectionSource({
|
|
269
|
+
ideasClient: client,
|
|
270
|
+
swarmCoordinator: makeCoord({ createImpl: created }),
|
|
271
|
+
getConfig: () => cfg,
|
|
272
|
+
updateConfig: async (_pr, patch) => {
|
|
273
|
+
patches.push(patch);
|
|
274
|
+
Object.assign(cfg, patch);
|
|
275
|
+
},
|
|
276
|
+
generateSwarmId: () => "swarm-x",
|
|
277
|
+
clock: () => new Date("2026-06-07T00:00:00Z"),
|
|
278
|
+
});
|
|
279
|
+
const result = await source.pick(projectRoot);
|
|
280
|
+
expect(result).toBeNull();
|
|
281
|
+
expect(created).toHaveBeenCalledTimes(1);
|
|
282
|
+
// PR-B D11 — introspection source stamps kind so UI / GC / shipment
|
|
283
|
+
// can tell these read-only "produce ideas" swarms apart from ordinary
|
|
284
|
+
// always-on cycles + user-driven chat swarms.
|
|
285
|
+
expect(created.mock.calls[0][1]).toMatchObject({ kind: "always-on-introspection" });
|
|
286
|
+
expect(client.creates).toHaveLength(0);
|
|
287
|
+
// Pending breadcrumb persisted along with cooldown stamp.
|
|
288
|
+
expect(patches.at(-1)).toMatchObject({
|
|
289
|
+
lastIntrospectionAt: "2026-06-07T00:00:00.000Z",
|
|
290
|
+
lastIntrospectionRejected: false,
|
|
291
|
+
pendingIntrospectionSwarmId: "swarm-x",
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
it("ingest cycle: pick() with pending swarm + terminal status reads output and POSTs", async () => {
|
|
295
|
+
mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
|
|
296
|
+
writeFileSync(outputPath, JSON.stringify([
|
|
297
|
+
{ title: "Idea A", body: "body A", priority: "high" },
|
|
298
|
+
{ title: "Idea B", body: "body B" },
|
|
299
|
+
]));
|
|
300
|
+
const client = makeClient();
|
|
301
|
+
const completedCalls = [];
|
|
302
|
+
const cfg = makeConfig({
|
|
303
|
+
// Pretend a prior spawn cycle landed a pending breadcrumb.
|
|
304
|
+
pendingIntrospectionSwarmId: "swarm-x",
|
|
305
|
+
lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
|
|
306
|
+
});
|
|
307
|
+
const source = createIntrospectionSource({
|
|
308
|
+
ideasClient: client,
|
|
309
|
+
swarmCoordinator: makeCoord({
|
|
310
|
+
swarmStatus: "completed",
|
|
311
|
+
onComplete: (id) => completedCalls.push(id),
|
|
312
|
+
}),
|
|
313
|
+
getConfig: () => cfg,
|
|
314
|
+
updateConfig: async (_pr, patch) => {
|
|
315
|
+
Object.assign(cfg, patch);
|
|
316
|
+
// simulate persistence by deleting the field when null
|
|
317
|
+
if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
|
|
318
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
clock: () => new Date("2026-06-07T01:00:00Z"),
|
|
322
|
+
});
|
|
323
|
+
const result = await source.pick(projectRoot);
|
|
324
|
+
expect(result).toBeNull();
|
|
325
|
+
expect(client.creates).toHaveLength(2);
|
|
326
|
+
expect(client.creates[0].priority).toBe("low");
|
|
327
|
+
expect(client.creates[0].title).toBe("Idea A");
|
|
328
|
+
expect(client.creates[0].tags).toContain(INTROSPECTION_SOURCE_TAG);
|
|
329
|
+
expect(client.creates[0].sourceRole).toBe(INTROSPECTION_SOURCE_ROLE);
|
|
330
|
+
expect(client.creates[0].sourceSwarmId).toBe("swarm-x");
|
|
331
|
+
// S2 hotfix: coordinator.complete was called on the terminal swarm.
|
|
332
|
+
expect(completedCalls).toEqual(["swarm-x"]);
|
|
333
|
+
// Pending breadcrumb cleared so next cycle can spawn fresh.
|
|
334
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
335
|
+
});
|
|
336
|
+
it("ingest cycle: caps at 3 ideas even when swarm produced 5", async () => {
|
|
337
|
+
mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
|
|
338
|
+
writeFileSync(outputPath, JSON.stringify(Array.from({ length: 5 }, (_, i) => ({ title: `Idea ${i + 1}`, body: "" }))));
|
|
339
|
+
const client = makeClient();
|
|
340
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-x" });
|
|
341
|
+
const source = createIntrospectionSource({
|
|
342
|
+
ideasClient: client,
|
|
343
|
+
swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
|
|
344
|
+
getConfig: () => cfg,
|
|
345
|
+
updateConfig: async (_pr, patch) => {
|
|
346
|
+
Object.assign(cfg, patch);
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
await source.pick(projectRoot);
|
|
350
|
+
expect(client.creates).toHaveLength(3);
|
|
351
|
+
expect(client.creates.map((i) => i.title)).toEqual([
|
|
352
|
+
"Idea 1",
|
|
353
|
+
"Idea 2",
|
|
354
|
+
"Idea 3",
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
357
|
+
it("ingest cycle: pending swarm still running → returns null, leaves pending", async () => {
|
|
358
|
+
const client = makeClient();
|
|
359
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-x" });
|
|
360
|
+
const patches = [];
|
|
361
|
+
const source = createIntrospectionSource({
|
|
362
|
+
ideasClient: client,
|
|
363
|
+
swarmCoordinator: {
|
|
364
|
+
create: async () => { },
|
|
365
|
+
getSwarm: () => ({ status: "running" }),
|
|
366
|
+
destroy: async () => { },
|
|
367
|
+
complete: async () => { },
|
|
368
|
+
},
|
|
369
|
+
getConfig: () => cfg,
|
|
370
|
+
updateConfig: async (_pr, patch) => {
|
|
371
|
+
patches.push(patch);
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
expect(await source.pick(projectRoot)).toBeNull();
|
|
375
|
+
expect(client.creates).toHaveLength(0);
|
|
376
|
+
// No update at all — pending stays as-is.
|
|
377
|
+
expect(patches).toHaveLength(0);
|
|
378
|
+
expect(cfg.pendingIntrospectionSwarmId).toBe("swarm-x");
|
|
379
|
+
});
|
|
380
|
+
it("ingest cycle: malformed output clears pending and posts nothing", async () => {
|
|
381
|
+
mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
|
|
382
|
+
writeFileSync(outputPath, "this is not json at all");
|
|
383
|
+
const client = makeClient();
|
|
384
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-x" });
|
|
385
|
+
let lastPatch = null;
|
|
386
|
+
const source = createIntrospectionSource({
|
|
387
|
+
ideasClient: client,
|
|
388
|
+
swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
|
|
389
|
+
getConfig: () => cfg,
|
|
390
|
+
updateConfig: async (_pr, patch) => {
|
|
391
|
+
lastPatch = patch;
|
|
392
|
+
Object.assign(cfg, patch);
|
|
393
|
+
if (patch.pendingIntrospectionSwarmId === null) {
|
|
394
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
395
|
+
}
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
await source.pick(projectRoot);
|
|
399
|
+
expect(client.creates).toHaveLength(0);
|
|
400
|
+
expect(lastPatch.pendingIntrospectionSwarmId).toBeNull();
|
|
401
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
402
|
+
});
|
|
403
|
+
it("ingest cycle: missing output file clears pending and posts nothing", async () => {
|
|
404
|
+
const client = makeClient();
|
|
405
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-y" });
|
|
406
|
+
let lastPatch = null;
|
|
407
|
+
const source = createIntrospectionSource({
|
|
408
|
+
ideasClient: client,
|
|
409
|
+
swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
|
|
410
|
+
getConfig: () => cfg,
|
|
411
|
+
updateConfig: async (_pr, patch) => {
|
|
412
|
+
lastPatch = patch;
|
|
413
|
+
Object.assign(cfg, patch);
|
|
414
|
+
if (patch.pendingIntrospectionSwarmId === null) {
|
|
415
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
generateSwarmId: () => "swarm-y",
|
|
419
|
+
});
|
|
420
|
+
await source.pick(projectRoot);
|
|
421
|
+
expect(client.creates).toHaveLength(0);
|
|
422
|
+
expect(lastPatch.pendingIntrospectionSwarmId).toBeNull();
|
|
423
|
+
});
|
|
424
|
+
it("spawn failure: bumps cooldown so no retry storm; pending NOT persisted", async () => {
|
|
425
|
+
const client = makeClient();
|
|
426
|
+
const patches = [];
|
|
427
|
+
const cfg = makeConfig();
|
|
428
|
+
const source = createIntrospectionSource({
|
|
429
|
+
ideasClient: client,
|
|
430
|
+
swarmCoordinator: makeCoord({
|
|
431
|
+
createImpl: async () => {
|
|
432
|
+
throw new Error("spawn failed");
|
|
433
|
+
},
|
|
434
|
+
}),
|
|
435
|
+
getConfig: () => cfg,
|
|
436
|
+
updateConfig: async (_pr, patch) => {
|
|
437
|
+
patches.push(patch);
|
|
438
|
+
Object.assign(cfg, patch);
|
|
439
|
+
},
|
|
440
|
+
clock: () => new Date("2026-06-07T03:00:00Z"),
|
|
441
|
+
});
|
|
442
|
+
await source.pick(projectRoot);
|
|
443
|
+
expect(client.creates).toHaveLength(0);
|
|
444
|
+
// Cooldown bumped; pending NOT set (the spawn was rejected by coordinator).
|
|
445
|
+
expect(patches[0]).toEqual({
|
|
446
|
+
lastIntrospectionAt: "2026-06-07T03:00:00.000Z",
|
|
447
|
+
lastIntrospectionRejected: false,
|
|
448
|
+
});
|
|
449
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
450
|
+
});
|
|
451
|
+
it("S1 hotfix: hub outage during POSTs rewinds lastIntrospectionAt so retry comes sooner", async () => {
|
|
452
|
+
mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
|
|
453
|
+
writeFileSync(outputPath, JSON.stringify([{ title: "Lonely Idea", body: "" }]));
|
|
454
|
+
const client = makeClient({ throwOnCreate: true });
|
|
455
|
+
const patches = [];
|
|
456
|
+
const cfg = makeConfig({
|
|
457
|
+
pendingIntrospectionSwarmId: "swarm-x",
|
|
458
|
+
lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
|
|
459
|
+
introspectionCooldownHours: 24,
|
|
460
|
+
});
|
|
461
|
+
const source = createIntrospectionSource({
|
|
462
|
+
ideasClient: client,
|
|
463
|
+
swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
|
|
464
|
+
getConfig: () => cfg,
|
|
465
|
+
updateConfig: async (_pr, patch) => {
|
|
466
|
+
patches.push(patch);
|
|
467
|
+
Object.assign(cfg, patch);
|
|
468
|
+
},
|
|
469
|
+
clock: () => new Date("2026-06-08T00:00:00Z"),
|
|
470
|
+
});
|
|
471
|
+
await source.pick(projectRoot);
|
|
472
|
+
expect(client.creates).toHaveLength(0);
|
|
473
|
+
// The patch should rewind lastIntrospectionAt to (now - 23h). At
|
|
474
|
+
// clock=2026-06-08T00:00:00Z the rewound stamp is 2026-06-07T01:00:00Z.
|
|
475
|
+
expect(patches[0]).toMatchObject({
|
|
476
|
+
pendingIntrospectionSwarmId: null,
|
|
477
|
+
lastIntrospectionAt: "2026-06-07T01:00:00.000Z",
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
// ── R3 I-A: terminal-state cleanup covers all 4 statuses ───────────────
|
|
482
|
+
describe("IntrospectionSource terminal-state cleanup (R3 I-A)", () => {
|
|
483
|
+
let tmpHome;
|
|
484
|
+
let projectRoot;
|
|
485
|
+
let outputPath;
|
|
486
|
+
beforeEach(() => {
|
|
487
|
+
tmpHome = mkdtempSync(join(tmpdir(), "introspect-r3-"));
|
|
488
|
+
projectRoot = tmpHome;
|
|
489
|
+
outputPath = join(projectRoot, ".clawnet", "introspection", "swarm-r3.json");
|
|
490
|
+
mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
|
|
491
|
+
writeFileSync(outputPath, JSON.stringify([{ title: "x", body: "" }]));
|
|
492
|
+
});
|
|
493
|
+
afterEach(() => {
|
|
494
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
495
|
+
});
|
|
496
|
+
it("status=completed → calls coordinator.complete (not fail)", async () => {
|
|
497
|
+
const completes = [];
|
|
498
|
+
const fails = [];
|
|
499
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
|
|
500
|
+
const source = createIntrospectionSource({
|
|
501
|
+
ideasClient: makeClient(),
|
|
502
|
+
swarmCoordinator: makeCoord({
|
|
503
|
+
swarmStatus: "completed",
|
|
504
|
+
onComplete: (id) => completes.push(id),
|
|
505
|
+
onFail: (id) => fails.push(id),
|
|
506
|
+
}),
|
|
507
|
+
getConfig: () => cfg,
|
|
508
|
+
updateConfig: async (_pr, patch) => {
|
|
509
|
+
Object.assign(cfg, patch);
|
|
510
|
+
if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
|
|
511
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
await source.pick(projectRoot);
|
|
516
|
+
expect(completes).toEqual(["swarm-r3"]);
|
|
517
|
+
expect(fails).toEqual([]);
|
|
518
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
519
|
+
});
|
|
520
|
+
it("status=failed → calls coordinator.fail (not complete)", async () => {
|
|
521
|
+
const completes = [];
|
|
522
|
+
const fails = [];
|
|
523
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
|
|
524
|
+
const source = createIntrospectionSource({
|
|
525
|
+
ideasClient: makeClient(),
|
|
526
|
+
swarmCoordinator: makeCoord({
|
|
527
|
+
swarmStatus: "failed",
|
|
528
|
+
onComplete: (id) => completes.push(id),
|
|
529
|
+
onFail: (id) => fails.push(id),
|
|
530
|
+
}),
|
|
531
|
+
getConfig: () => cfg,
|
|
532
|
+
updateConfig: async (_pr, patch) => {
|
|
533
|
+
Object.assign(cfg, patch);
|
|
534
|
+
if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
|
|
535
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
await source.pick(projectRoot);
|
|
540
|
+
expect(fails).toEqual(["swarm-r3"]);
|
|
541
|
+
expect(completes).toEqual([]);
|
|
542
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
543
|
+
});
|
|
544
|
+
it("status=cancelled → no coordinator call (already terminal externally) but pending cleared", async () => {
|
|
545
|
+
const completes = [];
|
|
546
|
+
const fails = [];
|
|
547
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
|
|
548
|
+
const source = createIntrospectionSource({
|
|
549
|
+
ideasClient: makeClient(),
|
|
550
|
+
swarmCoordinator: makeCoord({
|
|
551
|
+
swarmStatus: "cancelled",
|
|
552
|
+
onComplete: (id) => completes.push(id),
|
|
553
|
+
onFail: (id) => fails.push(id),
|
|
554
|
+
}),
|
|
555
|
+
getConfig: () => cfg,
|
|
556
|
+
updateConfig: async (_pr, patch) => {
|
|
557
|
+
Object.assign(cfg, patch);
|
|
558
|
+
if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
|
|
559
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
await source.pick(projectRoot);
|
|
564
|
+
expect(completes).toEqual([]);
|
|
565
|
+
expect(fails).toEqual([]);
|
|
566
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
567
|
+
});
|
|
568
|
+
it("status=unknown (gone from registry) → no coordinator call but pending cleared", async () => {
|
|
569
|
+
const completes = [];
|
|
570
|
+
const fails = [];
|
|
571
|
+
const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
|
|
572
|
+
const source = createIntrospectionSource({
|
|
573
|
+
ideasClient: makeClient(),
|
|
574
|
+
swarmCoordinator: makeCoord({
|
|
575
|
+
swarmStatus: null, // → getSwarm returns undefined → status=unknown
|
|
576
|
+
onComplete: (id) => completes.push(id),
|
|
577
|
+
onFail: (id) => fails.push(id),
|
|
578
|
+
}),
|
|
579
|
+
getConfig: () => cfg,
|
|
580
|
+
updateConfig: async (_pr, patch) => {
|
|
581
|
+
Object.assign(cfg, patch);
|
|
582
|
+
if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
|
|
583
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
});
|
|
587
|
+
await source.pick(projectRoot);
|
|
588
|
+
expect(completes).toEqual([]);
|
|
589
|
+
expect(fails).toEqual([]);
|
|
590
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
// ── Self-suppression ───────────────────────────────────────────────────
|
|
594
|
+
describe("IntrospectionSource self-suppression", () => {
|
|
595
|
+
it("sets lastIntrospectionRejected=true when all previous cohort archived", async () => {
|
|
596
|
+
const lastAt = new Date("2026-06-06T00:00:00Z").toISOString();
|
|
597
|
+
const cohort = [
|
|
598
|
+
makeIdea({
|
|
599
|
+
id: "i1",
|
|
600
|
+
status: "archived",
|
|
601
|
+
sourceRole: INTROSPECTION_SOURCE_ROLE,
|
|
602
|
+
tags: [INTROSPECTION_SOURCE_TAG],
|
|
603
|
+
createdAt: "2026-06-06T00:01:00Z",
|
|
604
|
+
}),
|
|
605
|
+
makeIdea({
|
|
606
|
+
id: "i2",
|
|
607
|
+
status: "archived",
|
|
608
|
+
sourceRole: INTROSPECTION_SOURCE_ROLE,
|
|
609
|
+
tags: [INTROSPECTION_SOURCE_TAG],
|
|
610
|
+
createdAt: "2026-06-06T00:02:00Z",
|
|
611
|
+
}),
|
|
612
|
+
];
|
|
613
|
+
const cfg = makeConfig({
|
|
614
|
+
lastIntrospectionAt: lastAt,
|
|
615
|
+
lastIntrospectionRejected: false,
|
|
616
|
+
});
|
|
617
|
+
const patches = [];
|
|
618
|
+
const source = createIntrospectionSource({
|
|
619
|
+
ideasClient: makeClient({ listResults: cohort }),
|
|
620
|
+
swarmCoordinator: makeCoord(),
|
|
621
|
+
getConfig: () => cfg,
|
|
622
|
+
updateConfig: async (_pr, patch) => {
|
|
623
|
+
patches.push(patch);
|
|
624
|
+
Object.assign(cfg, patch);
|
|
625
|
+
},
|
|
626
|
+
clock: () => new Date("2026-06-08T00:00:00Z"), // past 24h cooldown
|
|
627
|
+
});
|
|
628
|
+
const result = await source.pick(PROJECT_ROOT);
|
|
629
|
+
expect(result).toBeNull();
|
|
630
|
+
expect(patches[0]).toEqual({ lastIntrospectionRejected: true });
|
|
631
|
+
});
|
|
632
|
+
it("clears lastIntrospectionRejected when ≥1 previous idea survives", async () => {
|
|
633
|
+
const lastAt = new Date("2026-06-06T00:00:00Z").toISOString();
|
|
634
|
+
const cohort = [
|
|
635
|
+
makeIdea({
|
|
636
|
+
id: "i1",
|
|
637
|
+
status: "todo", // promoted by user
|
|
638
|
+
sourceRole: INTROSPECTION_SOURCE_ROLE,
|
|
639
|
+
tags: [INTROSPECTION_SOURCE_TAG],
|
|
640
|
+
createdAt: "2026-06-06T00:01:00Z",
|
|
641
|
+
}),
|
|
642
|
+
makeIdea({
|
|
643
|
+
id: "i2",
|
|
644
|
+
status: "archived",
|
|
645
|
+
sourceRole: INTROSPECTION_SOURCE_ROLE,
|
|
646
|
+
tags: [INTROSPECTION_SOURCE_TAG],
|
|
647
|
+
createdAt: "2026-06-06T00:02:00Z",
|
|
648
|
+
}),
|
|
649
|
+
];
|
|
650
|
+
const cfg = makeConfig({
|
|
651
|
+
lastIntrospectionAt: lastAt,
|
|
652
|
+
lastIntrospectionRejected: true, // stale flag from prior cohort
|
|
653
|
+
});
|
|
654
|
+
const patches = [];
|
|
655
|
+
const source = createIntrospectionSource({
|
|
656
|
+
ideasClient: makeClient({ listResults: cohort }),
|
|
657
|
+
swarmCoordinator: makeCoord(),
|
|
658
|
+
getConfig: () => cfg,
|
|
659
|
+
updateConfig: async (_pr, patch) => {
|
|
660
|
+
patches.push(patch);
|
|
661
|
+
Object.assign(cfg, patch);
|
|
662
|
+
},
|
|
663
|
+
clock: () => new Date("2026-06-08T00:00:00Z"),
|
|
664
|
+
});
|
|
665
|
+
await source.pick(PROJECT_ROOT);
|
|
666
|
+
// First patch clears the suppression flag.
|
|
667
|
+
expect(patches[0]).toEqual({ lastIntrospectionRejected: false });
|
|
668
|
+
// Cycle then runs to spawn → bumps the cooldown breadcrumbs +
|
|
669
|
+
// pendingIntrospectionSwarmId.
|
|
670
|
+
expect(patches.at(-1)).toMatchObject({
|
|
671
|
+
lastIntrospectionAt: expect.any(String),
|
|
672
|
+
lastIntrospectionRejected: false,
|
|
673
|
+
pendingIntrospectionSwarmId: expect.any(String),
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
it("ignores cohort entries older than lastIntrospectionAt (with epsilon)", async () => {
|
|
677
|
+
const lastAt = new Date("2026-06-06T12:00:00Z").toISOString();
|
|
678
|
+
const cohort = [
|
|
679
|
+
makeIdea({
|
|
680
|
+
id: "old-archived",
|
|
681
|
+
status: "archived",
|
|
682
|
+
sourceRole: INTROSPECTION_SOURCE_ROLE,
|
|
683
|
+
tags: [INTROSPECTION_SOURCE_TAG],
|
|
684
|
+
createdAt: "2026-06-01T00:00:00Z", // way before lastIntrospectionAt
|
|
685
|
+
}),
|
|
686
|
+
makeIdea({
|
|
687
|
+
id: "new-todo",
|
|
688
|
+
status: "todo",
|
|
689
|
+
sourceRole: INTROSPECTION_SOURCE_ROLE,
|
|
690
|
+
tags: [INTROSPECTION_SOURCE_TAG],
|
|
691
|
+
createdAt: "2026-06-06T12:00:30Z", // within epsilon of lastIntrospectionAt
|
|
692
|
+
}),
|
|
693
|
+
];
|
|
694
|
+
const cfg = makeConfig({
|
|
695
|
+
lastIntrospectionAt: lastAt,
|
|
696
|
+
});
|
|
697
|
+
const patches = [];
|
|
698
|
+
const source = createIntrospectionSource({
|
|
699
|
+
ideasClient: makeClient({ listResults: cohort }),
|
|
700
|
+
swarmCoordinator: makeCoord(),
|
|
701
|
+
getConfig: () => cfg,
|
|
702
|
+
updateConfig: async (_pr, patch) => {
|
|
703
|
+
patches.push(patch);
|
|
704
|
+
Object.assign(cfg, patch);
|
|
705
|
+
},
|
|
706
|
+
clock: () => new Date("2026-06-08T00:00:00Z"),
|
|
707
|
+
});
|
|
708
|
+
await source.pick(PROJECT_ROOT);
|
|
709
|
+
// The "new-todo" entry survives the filter and proves the cohort isn't
|
|
710
|
+
// all-archived → flag should be false, not true.
|
|
711
|
+
expect(patches[0]).toEqual({ lastIntrospectionRejected: false });
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
// ── B2c: spawner-owned lifecycle (Framing B) ───────────────────────────
|
|
715
|
+
//
|
|
716
|
+
// Verifies that IntrospectionSource declares "owned" lifecycle, exposes a
|
|
717
|
+
// public isComplete() hook, and that drainPending forces the coordinator
|
|
718
|
+
// into a terminal state when the spawner-side completion signal (output
|
|
719
|
+
// file + mtime guard, or hard timeout) is observed — even if the swarm is
|
|
720
|
+
// stuck in `paused` or `running` because the coordinator's idle-fallback
|
|
721
|
+
// hasn't triggered yet.
|
|
722
|
+
describe("IntrospectionSource spawner-owned lifecycle (B2c)", () => {
|
|
723
|
+
let tmpHome;
|
|
724
|
+
let projectRoot;
|
|
725
|
+
let outputPath;
|
|
726
|
+
let nowMs;
|
|
727
|
+
let spawnIso;
|
|
728
|
+
beforeEach(() => {
|
|
729
|
+
tmpHome = mkdtempSync(join(tmpdir(), "introspect-b2c-"));
|
|
730
|
+
projectRoot = tmpHome;
|
|
731
|
+
outputPath = join(projectRoot, ".clawnet", "introspection", "swarm-b2c.json");
|
|
732
|
+
mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
|
|
733
|
+
// Anchor all time math to wall-clock now so file mtime and injected
|
|
734
|
+
// clock share the same axis. Each test sets nowMs forward as needed
|
|
735
|
+
// (e.g. +60s past mtime guard, +3h past hard timeout) using offsets
|
|
736
|
+
// relative to spawnIso.
|
|
737
|
+
nowMs = Date.now();
|
|
738
|
+
spawnIso = new Date(nowMs).toISOString();
|
|
739
|
+
});
|
|
740
|
+
afterEach(() => {
|
|
741
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
742
|
+
});
|
|
743
|
+
it("exposes isComplete (public spawner-owned signal hook)", () => {
|
|
744
|
+
const source = createIntrospectionSource({
|
|
745
|
+
ideasClient: makeClient(),
|
|
746
|
+
swarmCoordinator: makeCoord(),
|
|
747
|
+
getConfig: () => makeConfig(),
|
|
748
|
+
updateConfig: async () => { },
|
|
749
|
+
});
|
|
750
|
+
expect(typeof source.isComplete).toBe("function");
|
|
751
|
+
});
|
|
752
|
+
it("isComplete: false when no output file and within hard timeout", async () => {
|
|
753
|
+
const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
|
|
754
|
+
const source = createIntrospectionSource({
|
|
755
|
+
ideasClient: makeClient(),
|
|
756
|
+
swarmCoordinator: makeCoord(),
|
|
757
|
+
getConfig: () => cfg,
|
|
758
|
+
updateConfig: async () => { },
|
|
759
|
+
clock: () => new Date(nowMs + SPAWNER_COMPLETE_HARD_TIMEOUT_MS / 2),
|
|
760
|
+
});
|
|
761
|
+
expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(false);
|
|
762
|
+
});
|
|
763
|
+
it("isComplete: false when output just written (mtime guard)", async () => {
|
|
764
|
+
writeFileSync(outputPath, JSON.stringify([{ title: "x" }]));
|
|
765
|
+
const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
|
|
766
|
+
const source = createIntrospectionSource({
|
|
767
|
+
ideasClient: makeClient(),
|
|
768
|
+
swarmCoordinator: makeCoord(),
|
|
769
|
+
getConfig: () => cfg,
|
|
770
|
+
updateConfig: async () => { },
|
|
771
|
+
// clock pinned to inside the mtime guard window
|
|
772
|
+
clock: () => new Date(nowMs + Math.floor(SPAWNER_COMPLETE_MTIME_GUARD_MS / 2)),
|
|
773
|
+
});
|
|
774
|
+
expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(false);
|
|
775
|
+
});
|
|
776
|
+
it("isComplete: true when output stable beyond mtime guard", async () => {
|
|
777
|
+
writeFileSync(outputPath, JSON.stringify([{ title: "x" }]));
|
|
778
|
+
const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
|
|
779
|
+
const source = createIntrospectionSource({
|
|
780
|
+
ideasClient: makeClient(),
|
|
781
|
+
swarmCoordinator: makeCoord(),
|
|
782
|
+
getConfig: () => cfg,
|
|
783
|
+
updateConfig: async () => { },
|
|
784
|
+
clock: () => new Date(nowMs + SPAWNER_COMPLETE_MTIME_GUARD_MS + 1_000),
|
|
785
|
+
});
|
|
786
|
+
expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(true);
|
|
787
|
+
});
|
|
788
|
+
it("isComplete: true when hard timeout elapsed even without output", async () => {
|
|
789
|
+
const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
|
|
790
|
+
const source = createIntrospectionSource({
|
|
791
|
+
ideasClient: makeClient(),
|
|
792
|
+
swarmCoordinator: makeCoord(),
|
|
793
|
+
getConfig: () => cfg,
|
|
794
|
+
updateConfig: async () => { },
|
|
795
|
+
clock: () => new Date(nowMs + SPAWNER_COMPLETE_HARD_TIMEOUT_MS + 60_000),
|
|
796
|
+
});
|
|
797
|
+
expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(true);
|
|
798
|
+
});
|
|
799
|
+
it("drainPending: swarm=paused + isComplete=false → no force, no ingest, pendingId kept", async () => {
|
|
800
|
+
// No output file → isComplete=false even though swarm is non-terminal
|
|
801
|
+
const completes = [];
|
|
802
|
+
const cfg = makeConfig({
|
|
803
|
+
lastIntrospectionAt: spawnIso,
|
|
804
|
+
pendingIntrospectionSwarmId: "swarm-b2c",
|
|
805
|
+
});
|
|
806
|
+
const patches = [];
|
|
807
|
+
const source = createIntrospectionSource({
|
|
808
|
+
ideasClient: makeClient(),
|
|
809
|
+
swarmCoordinator: makeCoord({
|
|
810
|
+
swarmStatus: "paused",
|
|
811
|
+
onComplete: (id) => completes.push(id),
|
|
812
|
+
}),
|
|
813
|
+
getConfig: () => cfg,
|
|
814
|
+
updateConfig: async (_pr, patch) => {
|
|
815
|
+
patches.push(patch);
|
|
816
|
+
},
|
|
817
|
+
clock: () => new Date(nowMs + Math.floor(SPAWNER_COMPLETE_HARD_TIMEOUT_MS / 4)),
|
|
818
|
+
});
|
|
819
|
+
await source.drainPending(projectRoot);
|
|
820
|
+
expect(completes).toEqual([]); // no force
|
|
821
|
+
expect(patches).toEqual([]); // pendingId not cleared
|
|
822
|
+
});
|
|
823
|
+
it("drainPending: swarm=paused + isComplete=true → force complete + R3 I-A skips (production parity)", async () => {
|
|
824
|
+
writeFileSync(outputPath, JSON.stringify([
|
|
825
|
+
{ title: "i1", body: "b1" },
|
|
826
|
+
{ title: "i2", body: "b2" },
|
|
827
|
+
]));
|
|
828
|
+
const completes = [];
|
|
829
|
+
const creates = [];
|
|
830
|
+
const cfg = makeConfig({
|
|
831
|
+
lastIntrospectionAt: spawnIso,
|
|
832
|
+
pendingIntrospectionSwarmId: "swarm-b2c",
|
|
833
|
+
});
|
|
834
|
+
const patches = [];
|
|
835
|
+
const source = createIntrospectionSource({
|
|
836
|
+
ideasClient: makeClient({ onCreate: (i) => creates.push(i) }),
|
|
837
|
+
// mutateStatusOnComplete=true: mirrors real coord which calls
|
|
838
|
+
// runRetroAndCleanup → swarms.delete after marking completed. So the
|
|
839
|
+
// post-complete isTerminalSwarm sees "unknown" (swarm gone), NOT
|
|
840
|
+
// "completed". The R3 I-A cleanup chain in processPendingSwarm
|
|
841
|
+
// then correctly skips firing a second complete() — which is the
|
|
842
|
+
// ACTUAL production behaviour. An earlier round of this test had a
|
|
843
|
+
// fake that lied about this and asserted 2 calls, masking the real
|
|
844
|
+
// single-call flow. See PR #170 Round-3 review.
|
|
845
|
+
swarmCoordinator: makeCoord({
|
|
846
|
+
swarmStatus: "paused",
|
|
847
|
+
mutateStatusOnComplete: true,
|
|
848
|
+
onComplete: (id) => completes.push(id),
|
|
849
|
+
}),
|
|
850
|
+
getConfig: () => cfg,
|
|
851
|
+
updateConfig: async (_pr, patch) => {
|
|
852
|
+
patches.push(patch);
|
|
853
|
+
Object.assign(cfg, patch);
|
|
854
|
+
},
|
|
855
|
+
clock: () => new Date(nowMs + SPAWNER_COMPLETE_MTIME_GUARD_MS + 1_000),
|
|
856
|
+
});
|
|
857
|
+
await source.drainPending(projectRoot);
|
|
858
|
+
// Exactly once: the spawner-owned force-complete. R3 I-A sees "unknown"
|
|
859
|
+
// (registry evicted) and skips its own complete/fail branches.
|
|
860
|
+
// Catches both regression directions — missing the force AND
|
|
861
|
+
// accidentally double-firing the cleanup chain.
|
|
862
|
+
expect(completes).toEqual(["swarm-b2c"]);
|
|
863
|
+
expect(creates).toHaveLength(2);
|
|
864
|
+
expect(creates.map((c) => c.title)).toEqual(["i1", "i2"]);
|
|
865
|
+
const clearPatch = patches.find((p) => "pendingIntrospectionSwarmId" in p);
|
|
866
|
+
expect(clearPatch?.pendingIntrospectionSwarmId).toBeNull();
|
|
867
|
+
});
|
|
868
|
+
it("drainPending: spawner-owned complete throws → destroy fallback + ingest still proceeds", async () => {
|
|
869
|
+
writeFileSync(outputPath, JSON.stringify([{ title: "still-ingested" }]));
|
|
870
|
+
const creates = [];
|
|
871
|
+
const destroys = [];
|
|
872
|
+
const cfg = makeConfig({
|
|
873
|
+
lastIntrospectionAt: spawnIso,
|
|
874
|
+
pendingIntrospectionSwarmId: "swarm-b2c",
|
|
875
|
+
});
|
|
876
|
+
const patches = [];
|
|
877
|
+
const coord = makeCoord({
|
|
878
|
+
swarmStatus: "paused",
|
|
879
|
+
completeThrows: true,
|
|
880
|
+
onDestroy: (id) => destroys.push(id),
|
|
881
|
+
});
|
|
882
|
+
const source = createIntrospectionSource({
|
|
883
|
+
ideasClient: makeClient({ onCreate: (i) => creates.push(i) }),
|
|
884
|
+
swarmCoordinator: coord,
|
|
885
|
+
getConfig: () => cfg,
|
|
886
|
+
updateConfig: async (_pr, patch) => {
|
|
887
|
+
patches.push(patch);
|
|
888
|
+
Object.assign(cfg, patch);
|
|
889
|
+
},
|
|
890
|
+
clock: () => new Date(nowMs + SPAWNER_COMPLETE_MTIME_GUARD_MS + 1_000),
|
|
891
|
+
});
|
|
892
|
+
// Should NOT throw — best-effort transition + destroy fallback
|
|
893
|
+
await source.drainPending(projectRoot);
|
|
894
|
+
// Registry leak prevention: destroy was called AND the registry
|
|
895
|
+
// actually evicted the swarm (fake mirrors real coord behaviour).
|
|
896
|
+
expect(destroys).toEqual(["swarm-b2c"]);
|
|
897
|
+
expect(coord.getSwarm("swarm-b2c")).toBeUndefined();
|
|
898
|
+
expect(creates).toHaveLength(1); // ingest happened anyway
|
|
899
|
+
const clearPatch = patches.find((p) => "pendingIntrospectionSwarmId" in p);
|
|
900
|
+
expect(clearPatch?.pendingIntrospectionSwarmId).toBeNull();
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
// ── PR-B follow-up: workspace.cwd race fix ─────────────────────────────
|
|
904
|
+
//
|
|
905
|
+
// After PR-B Sub-PR-B2 added the queen-driven `swarm_complete` path, the
|
|
906
|
+
// coordinator deletes the swarm from its registry BEFORE the next tick's
|
|
907
|
+
// `processPendingSwarm` runs. `resolveActualOutputPath` previously relied
|
|
908
|
+
// on `swarmCoordinator.getSwarm(swarmId).workspace?.cwd` to find where the
|
|
909
|
+
// queen wrote candidates — post-delete it returns undefined and the
|
|
910
|
+
// fallback (projectRoot) finds no file → silent 0 ingest. Fix: persist
|
|
911
|
+
// `workspace.cwd` to `always-on.json` alongside `pendingIntrospectionSwarmId`
|
|
912
|
+
// at spawn time, and have the resolver prefer the persisted value when
|
|
913
|
+
// `getSwarm()` is empty.
|
|
914
|
+
describe("IntrospectionSource workspace.cwd persistence (PR-B race fix)", () => {
|
|
915
|
+
let tmpHome;
|
|
916
|
+
let projectRoot;
|
|
917
|
+
let worktreeCwd;
|
|
918
|
+
beforeEach(() => {
|
|
919
|
+
tmpHome = mkdtempSync(join(tmpdir(), "introspect-race-"));
|
|
920
|
+
projectRoot = tmpHome;
|
|
921
|
+
worktreeCwd = join(tmpHome, ".worktrees", "swarm-x");
|
|
922
|
+
mkdirSync(join(worktreeCwd, ".clawnet", "introspection"), { recursive: true });
|
|
923
|
+
});
|
|
924
|
+
afterEach(() => {
|
|
925
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
926
|
+
});
|
|
927
|
+
it("spawn cycle persists pendingIntrospectionWorkspaceCwd when WorkspaceManager assigns a cwd", async () => {
|
|
928
|
+
const client = makeClient();
|
|
929
|
+
const created = vi.fn(async (_swarmId, _opts) => { });
|
|
930
|
+
const patches = [];
|
|
931
|
+
const cfg = makeConfig();
|
|
932
|
+
// Custom coord that returns workspace.cwd on getSwarm so the spawn
|
|
933
|
+
// path can capture it. Mirrors WorkspaceManager.create having already
|
|
934
|
+
// assigned a workspace before swarmCoordinator.create returns.
|
|
935
|
+
const coord = {
|
|
936
|
+
create: created,
|
|
937
|
+
getSwarm: (_id) => ({ status: "running", workspace: { cwd: worktreeCwd } }),
|
|
938
|
+
destroy: async () => { },
|
|
939
|
+
complete: async () => { },
|
|
940
|
+
fail: async () => { },
|
|
941
|
+
};
|
|
942
|
+
const source = createIntrospectionSource({
|
|
943
|
+
ideasClient: client,
|
|
944
|
+
swarmCoordinator: coord,
|
|
945
|
+
getConfig: () => cfg,
|
|
946
|
+
updateConfig: async (_pr, patch) => {
|
|
947
|
+
patches.push(patch);
|
|
948
|
+
Object.assign(cfg, patch);
|
|
949
|
+
},
|
|
950
|
+
generateSwarmId: () => "swarm-x",
|
|
951
|
+
clock: () => new Date("2026-06-07T00:00:00Z"),
|
|
952
|
+
});
|
|
953
|
+
await source.pick(projectRoot);
|
|
954
|
+
// Final patch carries both the swarm id and the workspace cwd.
|
|
955
|
+
expect(patches.at(-1)).toMatchObject({
|
|
956
|
+
pendingIntrospectionSwarmId: "swarm-x",
|
|
957
|
+
pendingIntrospectionWorkspaceCwd: worktreeCwd,
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
it("ingest reads candidates from persisted workspace.cwd when swarm is gone from registry (B2 path)", async () => {
|
|
961
|
+
// Queen wrote 3 candidates to the worktree; swarm_complete has already
|
|
962
|
+
// run and deleted the swarm from the registry. The pendingIntrospection
|
|
963
|
+
// breadcrumb + persisted cwd are still on disk.
|
|
964
|
+
const queenOutput = join(worktreeCwd, ".clawnet", "introspection", "swarm-x.json");
|
|
965
|
+
writeFileSync(queenOutput, JSON.stringify([
|
|
966
|
+
{ title: "inFlight leak fix", body: "raise watchdog" },
|
|
967
|
+
{ title: "true-release auto-pause", body: "closeSession + dispose" },
|
|
968
|
+
{ title: "continuationSwarmId", body: "carry conversation across cycles" },
|
|
969
|
+
]));
|
|
970
|
+
const client = makeClient();
|
|
971
|
+
const cfg = makeConfig({
|
|
972
|
+
pendingIntrospectionSwarmId: "swarm-x",
|
|
973
|
+
pendingIntrospectionWorkspaceCwd: worktreeCwd,
|
|
974
|
+
// Spawn far enough ago to clear isOwnerComplete mtime guard, and far
|
|
975
|
+
// enough behind the hard timeout that we don't trip the hard-timeout
|
|
976
|
+
// branch by accident.
|
|
977
|
+
lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
|
|
978
|
+
});
|
|
979
|
+
// Coordinator no longer has the swarm — swarm_complete already deleted
|
|
980
|
+
// it. getSwarm returns undefined; isTerminalSwarm reports "unknown".
|
|
981
|
+
const coord = makeCoord({ swarmStatus: null });
|
|
982
|
+
const patches = [];
|
|
983
|
+
const source = createIntrospectionSource({
|
|
984
|
+
ideasClient: client,
|
|
985
|
+
swarmCoordinator: coord,
|
|
986
|
+
getConfig: () => cfg,
|
|
987
|
+
updateConfig: async (_pr, patch) => {
|
|
988
|
+
patches.push(patch);
|
|
989
|
+
Object.assign(cfg, patch);
|
|
990
|
+
if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
|
|
991
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
992
|
+
}
|
|
993
|
+
if ("pendingIntrospectionWorkspaceCwd" in patch && patch.pendingIntrospectionWorkspaceCwd === null) {
|
|
994
|
+
delete cfg.pendingIntrospectionWorkspaceCwd;
|
|
995
|
+
}
|
|
996
|
+
},
|
|
997
|
+
// Far enough past spawnAt that the mtime guard (5s) has elapsed,
|
|
998
|
+
// but well within hard timeout (2h).
|
|
999
|
+
clock: () => new Date("2026-06-07T00:00:10Z"),
|
|
1000
|
+
});
|
|
1001
|
+
await source.drainPending(projectRoot);
|
|
1002
|
+
// ALL 3 candidates were ingested — the fix saves the cohort that
|
|
1003
|
+
// would otherwise be silently lost.
|
|
1004
|
+
expect(client.creates).toHaveLength(3);
|
|
1005
|
+
expect(client.creates.map((c) => c.title)).toEqual([
|
|
1006
|
+
"inFlight leak fix",
|
|
1007
|
+
"true-release auto-pause",
|
|
1008
|
+
"continuationSwarmId",
|
|
1009
|
+
]);
|
|
1010
|
+
// Both breadcrumbs cleared in lockstep.
|
|
1011
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
1012
|
+
expect(cfg.pendingIntrospectionWorkspaceCwd).toBeUndefined();
|
|
1013
|
+
const clearPatch = patches.find((p) => "pendingIntrospectionSwarmId" in p);
|
|
1014
|
+
expect(clearPatch?.pendingIntrospectionWorkspaceCwd).toBeNull();
|
|
1015
|
+
});
|
|
1016
|
+
it("regression: without persisted cwd, post-delete drain silently loses candidates (control)", async () => {
|
|
1017
|
+
// Same setup as above EXCEPT pendingIntrospectionWorkspaceCwd is absent
|
|
1018
|
+
// — this is the pre-fix state. Without persisted cwd, the resolver
|
|
1019
|
+
// falls back to projectRoot/.clawnet/introspection/, where the file
|
|
1020
|
+
// does NOT live, so 0 candidates are ingested. This test guards the
|
|
1021
|
+
// fix by demonstrating the bug it solves.
|
|
1022
|
+
const queenOutput = join(worktreeCwd, ".clawnet", "introspection", "swarm-x.json");
|
|
1023
|
+
writeFileSync(queenOutput, JSON.stringify([{ title: "would-be-lost", body: "" }]));
|
|
1024
|
+
const client = makeClient();
|
|
1025
|
+
const cfg = makeConfig({
|
|
1026
|
+
pendingIntrospectionSwarmId: "swarm-x",
|
|
1027
|
+
// pendingIntrospectionWorkspaceCwd intentionally absent
|
|
1028
|
+
lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
|
|
1029
|
+
});
|
|
1030
|
+
const coord = makeCoord({ swarmStatus: null });
|
|
1031
|
+
const source = createIntrospectionSource({
|
|
1032
|
+
ideasClient: client,
|
|
1033
|
+
swarmCoordinator: coord,
|
|
1034
|
+
getConfig: () => cfg,
|
|
1035
|
+
updateConfig: async (_pr, patch) => {
|
|
1036
|
+
Object.assign(cfg, patch);
|
|
1037
|
+
if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
|
|
1038
|
+
delete cfg.pendingIntrospectionSwarmId;
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
clock: () => new Date("2026-06-07T00:00:10Z"),
|
|
1042
|
+
});
|
|
1043
|
+
await source.drainPending(projectRoot);
|
|
1044
|
+
// Bug-state: candidate file existed but resolver looked at wrong path
|
|
1045
|
+
// → silent 0 ingest, breadcrumb still cleared. This is exactly the
|
|
1046
|
+
// failure mode that the persisted cwd fix above prevents.
|
|
1047
|
+
expect(client.creates).toHaveLength(0);
|
|
1048
|
+
expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
//# sourceMappingURL=introspection-source.test.js.map
|