@app-vitals/agent-runtime 0.0.1 → 0.1.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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +50 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,244 @@
1
+ /**
2
+ * agent/src/runtime/worktree-reaper.ts
3
+ *
4
+ * Stale-worktree age sweep (WTR-1.2) — a pure-filesystem background pass
5
+ * that force-removes `worktrees/<repo>-<branch>` directories that have gone
6
+ * stale (older than the configured cleanup_after_days),
7
+ * mirroring the workspace CLAUDE.md instruction that worktrees older than 14
8
+ * days are removed automatically.
9
+ *
10
+ * Unlike pr-state-reconciler.ts, this module makes NO GitHub or task-store
11
+ * calls — it only reads the synced policy refs, lists the worktrees/ directory,
12
+ * checks mtimes against an injected clock, and runs `git worktree remove
13
+ * --force` for stale matches. It reuses WTR-1.1's readCleanupMergedWorktrees
14
+ * / readCleanupAfterDays (check-helpers.ts) rather than reimplementing
15
+ * policy reads.
16
+ *
17
+ * Shape mirrors pr-state-reconciler.ts: an injected-deps interface, a pure
18
+ * `reconcileStaleWorktrees(deps)` core function, per-directory try/catch
19
+ * error isolation (console.error, not console.warn — matches
20
+ * reconcilePrState's per-record loop) so one bad directory can't abort the
21
+ * rest of the batch, and a `buildProductionDeps()` factory wiring real
22
+ * fs/git calls at the bottom.
23
+ *
24
+ * Wired into agent/src/index.ts's PR-state-reconciler setInterval (WTR-1.4)
25
+ * as a third, independent try/catch pass on the same tick — see the Step 5b
26
+ * doc comment in index.ts for the full rationale.
27
+ */
28
+
29
+ import { execFile } from "node:child_process";
30
+ import { readdirSync, statSync } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { promisify } from "node:util";
33
+ import { readCleanupAfterDays } from "./cleanup-after-days-ref.ts";
34
+ import { readCleanupMergedWorktrees } from "./cleanup-merged-worktrees-ref.ts";
35
+ import type { Clock } from "./clock.ts";
36
+ import { SystemClock } from "./clock.ts";
37
+ import { resolveWorkspacePath, splitOrgRepo } from "./workspace-paths.ts";
38
+
39
+ const execFileAsync = promisify(execFile);
40
+
41
+ // ─── Types ────────────────────────────────────────────────────────────────────
42
+
43
+ /** A single `removeWorktree(repo, dirname)` invocation — exported for test-fixture typing. */
44
+ export interface RemoveWorktreeCall {
45
+ repo: string;
46
+ dirname: string;
47
+ }
48
+
49
+ export interface WorktreeReaperDeps {
50
+ /** Reads the `cleanup_merged_worktrees` policy field. */
51
+ readCleanupMergedWorktrees: () => Promise<boolean>;
52
+ /** Reads the `cleanup_after_days` policy field. */
53
+ readCleanupAfterDays: () => Promise<number>;
54
+ /** Lists dirnames directly under worktrees/ (not full paths). */
55
+ listWorktreeDirs: () => string[];
56
+ /**
57
+ * Returns the agent's currently-configured repo scope, read fresh on
58
+ * every reconcileStaleWorktrees() call — mirrors PrStateReconcilerDeps's
59
+ * own getScopedRepos field, so a scope change (a repo added/removed via
60
+ * syncConfig()) is picked up on the very next reconcile pass instead of
61
+ * being frozen at first-tick memoization time.
62
+ */
63
+ getScopedRepos: () => string[];
64
+ /** mtime lookup for a given worktree dirname. */
65
+ statMtime: (dirname: string) => Date;
66
+ /** Force-removes a worktree: `git -C repos/{repo} worktree remove worktrees/{dirname} --force`. */
67
+ removeWorktree: (repo: string, dirname: string) => Promise<void>;
68
+ /** Injected time source — never call Date.now()/new Date() directly. */
69
+ clock: Clock;
70
+ }
71
+
72
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
73
+
74
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
75
+
76
+ /**
77
+ * Resolve the scoped repo that owns a worktree dirname via longest-prefix
78
+ * match.
79
+ *
80
+ * `worktrees/<dirname>` uses the BARE repo name convention (e.g.
81
+ * "ok-wow-agency-feat-foo"), but agentReposRef.get() (and therefore
82
+ * deps.getScopedRepos()) returns "org/repo"-formatted strings in production
83
+ * (see agent-repos-ref.ts's doc comment) — so every scoped entry is first
84
+ * normalized to its bare repo name via splitOrgRepo() (the same helper
85
+ * pr-state-reconciler.ts uses to derive its own worktree dirname prefix from
86
+ * `record.repo`) before comparing against dirname. An entry with no "/" is
87
+ * already bare and normalizes to itself, which keeps the pre-RCO-1.1 bare-
88
+ * name-only scope format meaningful and matching unchanged.
89
+ *
90
+ * Repo names can themselves contain dashes (e.g. "example-repo"), so a naive
91
+ * first-dash split of dirname is unsafe — instead every normalized bare name
92
+ * is checked as a candidate prefix (`dirname === bareName` or
93
+ * `dirname.startsWith(bareName + "-")`, so a match only counts at a "-"
94
+ * boundary, never mid-word) and the LONGEST matching bare name wins (so
95
+ * "example-repo" wins over a hypothetical shorter false-positive prefix like
96
+ * "example").
97
+ *
98
+ * Ambiguity guard: two DISTINCT scoped "org/repo" entries can normalize to
99
+ * the SAME bare name (e.g. "org-a/widget" and "org-b/widget" both ->
100
+ * "widget") — the bare-dirname worktree convention has no way to tell them
101
+ * apart. Rather than arbitrarily picking one org's repo and silently
102
+ * mismatching the worktree (which could point `git worktree remove` at the
103
+ * wrong repo's local clone), a same-bare-name collision among the
104
+ * longest-matching entries is treated as unmatched (null), same as "no
105
+ * match" from the caller's point of view, but logged distinctly by the
106
+ * caller-visible sentinel below so it doesn't read as an ordinary
107
+ * "no scoped repo matches" case in Sentry.
108
+ */
109
+ const AMBIGUOUS_MATCH = Symbol("ambiguous-match");
110
+
111
+ function resolveOwningRepo(
112
+ dirname: string,
113
+ scopedRepos: string[],
114
+ ): string | null | typeof AMBIGUOUS_MATCH {
115
+ let bestBareName: string | null = null;
116
+ let bestOriginals: Set<string> = new Set();
117
+
118
+ for (const repo of scopedRepos) {
119
+ const [, bareName] = splitOrgRepo(repo);
120
+ if (!bareName) continue; // defensive — e.g. a malformed "org/" entry
121
+ const isMatch = dirname === bareName || dirname.startsWith(`${bareName}-`);
122
+ if (!isMatch) continue;
123
+
124
+ if (bestBareName === null || bareName.length > bestBareName.length) {
125
+ bestBareName = bareName;
126
+ bestOriginals = new Set([repo]);
127
+ } else if (bareName.length === bestBareName.length) {
128
+ bestOriginals.add(repo);
129
+ }
130
+ }
131
+
132
+ if (bestBareName === null) return null;
133
+ // Multiple distinct scoped entries tied for the longest match and share
134
+ // the same normalized bare name — e.g. "org-a/widget" + "org-b/widget".
135
+ // Note a single scoped entry appearing twice (or one repo matching under
136
+ // two different-length prefixes) never reaches here since bestOriginals
137
+ // is reset whenever a strictly longer bareName is found.
138
+ if (bestOriginals.size > 1) return AMBIGUOUS_MATCH;
139
+ return bestBareName;
140
+ }
141
+
142
+ /** True when `mtime` is older than `cleanupAfterDays` days before `now`. */
143
+ function isStale(mtime: Date, now: Date, cleanupAfterDays: number): boolean {
144
+ const cutoff = now.getTime() - cleanupAfterDays * MS_PER_DAY;
145
+ return mtime.getTime() < cutoff;
146
+ }
147
+
148
+ // ─── Core logic ───────────────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * Scan worktrees/ for directories that have gone stale (older than the
152
+ * policy's cleanup_after_days) and force-remove each one via the injected
153
+ * removeWorktree dep. No-ops entirely (zero fs/git calls beyond reading the
154
+ * policy) when cleanup_merged_worktrees is false.
155
+ *
156
+ * A dirname whose prefix matches no scoped repo is skipped defensively
157
+ * (logged, not fatal). A single directory's removeWorktree failure is
158
+ * caught and logged; it does not abort processing of the remaining
159
+ * directories — mirrors pr-state-reconciler.ts's reconcilePrState per-record
160
+ * continue-on-error isolation.
161
+ */
162
+ export async function reconcileStaleWorktrees(
163
+ deps: WorktreeReaperDeps,
164
+ ): Promise<void> {
165
+ if (!(await deps.readCleanupMergedWorktrees())) return; // disabled — no fs/git calls at all
166
+
167
+ const cleanupAfterDays = await deps.readCleanupAfterDays();
168
+ const now = deps.clock.now();
169
+
170
+ const dirs = deps.listWorktreeDirs();
171
+ const scopedRepos = deps.getScopedRepos();
172
+
173
+ for (const dirname of dirs) {
174
+ const repo = resolveOwningRepo(dirname, scopedRepos);
175
+ if (repo === AMBIGUOUS_MATCH) {
176
+ console.error(
177
+ `[worktree-reaper] skipping ${dirname} — AMBIGUOUS: multiple distinct scoped org/repo entries normalize to the same bare repo name for this worktree dirname's prefix`,
178
+ );
179
+ continue;
180
+ }
181
+ if (repo === null) {
182
+ console.error(
183
+ `[worktree-reaper] skipping ${dirname} — no scoped repo matches this worktree dirname's prefix`,
184
+ );
185
+ continue;
186
+ }
187
+
188
+ try {
189
+ const mtime = deps.statMtime(dirname);
190
+ if (!isStale(mtime, now, cleanupAfterDays)) continue; // still fresh — leave untouched
191
+
192
+ await deps.removeWorktree(repo, dirname);
193
+ } catch (err) {
194
+ console.error(
195
+ `[worktree-reaper] failed to reconcile worktree ${dirname}:`,
196
+ err instanceof Error ? err.message : String(err),
197
+ );
198
+ }
199
+ }
200
+ }
201
+
202
+ // ─── Production deps ──────────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Production deps for `reconcileStaleWorktrees()`. Reads the policy refs and
206
+ * worktrees/ from the resolved workspace path, and shells out to `git
207
+ * worktree remove --force` for the actual removal.
208
+ */
209
+ export function buildProductionDeps(opts?: {
210
+ clock?: Clock;
211
+ getScopedRepos?: () => string[];
212
+ }): WorktreeReaperDeps {
213
+ const workspacePath = resolveWorkspacePath();
214
+
215
+ return {
216
+ readCleanupMergedWorktrees: () => readCleanupMergedWorktrees(workspacePath),
217
+ readCleanupAfterDays: () => readCleanupAfterDays(workspacePath),
218
+ listWorktreeDirs: () => {
219
+ try {
220
+ return readdirSync(join(workspacePath, "worktrees"), {
221
+ withFileTypes: true,
222
+ })
223
+ .filter((entry) => entry.isDirectory())
224
+ .map((entry) => entry.name);
225
+ } catch {
226
+ return [];
227
+ }
228
+ },
229
+ getScopedRepos: opts?.getScopedRepos ?? (() => []),
230
+ statMtime: (dirname: string) =>
231
+ statSync(join(workspacePath, "worktrees", dirname)).mtime,
232
+ removeWorktree: async (repo: string, dirname: string) => {
233
+ await execFileAsync("git", [
234
+ "-C",
235
+ join(workspacePath, "repos", repo),
236
+ "worktree",
237
+ "remove",
238
+ join(workspacePath, "worktrees", dirname),
239
+ "--force",
240
+ ]);
241
+ },
242
+ clock: opts?.clock ?? SystemClock(),
243
+ };
244
+ }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * agent/src/runtime/worktree-reaper.unit.test.ts
3
+ *
4
+ * Unit tests for reconcileStaleWorktrees() — a pure-filesystem background
5
+ * pass (WTR-1.2) that force-removes worktrees/ directories older than the
6
+ * configured cleanup_after_days threshold.
7
+ *
8
+ * Uses fully injected fs/git-exec/clock doubles — no real filesystem or git
9
+ * calls, no global.fetch/global.* overrides, time injected via FixedClock —
10
+ * per this repo's unit-test isolation contract.
11
+ */
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import { FixedClock } from "./clock.ts";
15
+ import {
16
+ type RemoveWorktreeCall,
17
+ reconcileStaleWorktrees,
18
+ type WorktreeReaperDeps,
19
+ } from "./worktree-reaper.ts";
20
+
21
+ // ─── Fakes ────────────────────────────────────────────────────────────────────
22
+
23
+ const FAKE_NOW = new Date("2026-07-28T00:00:00.000Z");
24
+
25
+ interface MakeDepsOptions {
26
+ cleanupMergedWorktrees?: boolean;
27
+ cleanupAfterDays?: number;
28
+ dirs?: string[];
29
+ scopedRepos?: string[];
30
+ /** dirname -> mtime. Any dirname not present here throws (defensive — every test must configure the dirs it lists). */
31
+ mtimes?: Record<string, Date>;
32
+ /** dirname -> Error to throw from removeWorktree, if configured. */
33
+ removeErrors?: Record<string, Error>;
34
+ now?: Date;
35
+ }
36
+
37
+ function makeDeps(opts: MakeDepsOptions = {}): {
38
+ deps: WorktreeReaperDeps;
39
+ removeCalls: RemoveWorktreeCall[];
40
+ } {
41
+ const {
42
+ cleanupMergedWorktrees = true,
43
+ cleanupAfterDays = 14,
44
+ dirs = [],
45
+ scopedRepos = [],
46
+ mtimes = {},
47
+ removeErrors = {},
48
+ now = FAKE_NOW,
49
+ } = opts;
50
+
51
+ const removeCalls: RemoveWorktreeCall[] = [];
52
+
53
+ const deps: WorktreeReaperDeps = {
54
+ readCleanupMergedWorktrees: async () => cleanupMergedWorktrees,
55
+ readCleanupAfterDays: async () => cleanupAfterDays,
56
+ listWorktreeDirs: () => dirs,
57
+ getScopedRepos: () => scopedRepos,
58
+ statMtime: (dirname: string) => {
59
+ const mtime = mtimes[dirname];
60
+ if (!mtime) {
61
+ throw new Error(`test fixture missing mtime for dirname: ${dirname}`);
62
+ }
63
+ return mtime;
64
+ },
65
+ removeWorktree: async (repo: string, dirname: string) => {
66
+ removeCalls.push({ repo, dirname });
67
+ const err = removeErrors[dirname];
68
+ if (err) throw err;
69
+ },
70
+ clock: FixedClock(now),
71
+ };
72
+
73
+ return { deps, removeCalls };
74
+ }
75
+
76
+ // ─── Tests ────────────────────────────────────────────────────────────────────
77
+
78
+ describe("reconcileStaleWorktrees", () => {
79
+ test("AC1: removes a worktree older than cleanup_after_days whose name's prefix matches a scoped repo", async () => {
80
+ // 20 days old, threshold 14 — stale.
81
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
82
+ const { deps, removeCalls } = makeDeps({
83
+ dirs: ["example-repo-feat-foo"],
84
+ scopedRepos: ["example-repo"],
85
+ mtimes: { "example-repo-feat-foo": oldMtime },
86
+ });
87
+
88
+ await reconcileStaleWorktrees(deps);
89
+
90
+ expect(removeCalls).toEqual([
91
+ { repo: "example-repo", dirname: "example-repo-feat-foo" },
92
+ ]);
93
+ });
94
+
95
+ test("AC2: leaves a worktree younger than the threshold untouched", async () => {
96
+ // 2 days old, threshold 14 — not stale.
97
+ const recentMtime = new Date("2026-07-26T00:00:00.000Z");
98
+ const { deps, removeCalls } = makeDeps({
99
+ dirs: ["example-repo-feat-foo"],
100
+ scopedRepos: ["example-repo"],
101
+ mtimes: { "example-repo-feat-foo": recentMtime },
102
+ });
103
+
104
+ await reconcileStaleWorktrees(deps);
105
+
106
+ expect(removeCalls).toEqual([]);
107
+ });
108
+
109
+ test("AC3: cleanup_merged_worktrees false — no directories scanned or removed", async () => {
110
+ let listCalled = false;
111
+ let statCalled = false;
112
+ const { deps, removeCalls } = makeDeps({
113
+ cleanupMergedWorktrees: false,
114
+ dirs: ["example-repo-feat-foo"],
115
+ scopedRepos: ["example-repo"],
116
+ mtimes: { "example-repo-feat-foo": new Date("2020-01-01T00:00:00.000Z") },
117
+ });
118
+ const wrappedDeps: WorktreeReaperDeps = {
119
+ ...deps,
120
+ listWorktreeDirs: () => {
121
+ listCalled = true;
122
+ return deps.listWorktreeDirs();
123
+ },
124
+ statMtime: (dirname: string) => {
125
+ statCalled = true;
126
+ return deps.statMtime(dirname);
127
+ },
128
+ };
129
+
130
+ await reconcileStaleWorktrees(wrappedDeps);
131
+
132
+ expect(listCalled).toBe(false);
133
+ expect(statCalled).toBe(false);
134
+ expect(removeCalls).toEqual([]);
135
+ });
136
+
137
+ test("AC4: dirname whose prefix matches no scoped repo is skipped defensively", async () => {
138
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
139
+ const { deps, removeCalls } = makeDeps({
140
+ dirs: ["unknown-repo-feat-foo"],
141
+ scopedRepos: ["example-repo", "shipwright"],
142
+ mtimes: { "unknown-repo-feat-foo": oldMtime },
143
+ });
144
+
145
+ await expect(reconcileStaleWorktrees(deps)).resolves.toBeUndefined();
146
+ expect(removeCalls).toEqual([]);
147
+ });
148
+
149
+ test("AC5: a single directory's removeWorktree failure is caught and logged; remaining directories still processed", async () => {
150
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
151
+ const { deps, removeCalls } = makeDeps({
152
+ dirs: ["example-repo-feat-broken", "shipwright-feat-fine"],
153
+ scopedRepos: ["example-repo", "shipwright"],
154
+ mtimes: {
155
+ "example-repo-feat-broken": oldMtime,
156
+ "shipwright-feat-fine": oldMtime,
157
+ },
158
+ removeErrors: {
159
+ "example-repo-feat-broken": new Error("git worktree remove failed"),
160
+ },
161
+ });
162
+
163
+ await expect(reconcileStaleWorktrees(deps)).resolves.toBeUndefined();
164
+
165
+ expect(removeCalls).toEqual([
166
+ { repo: "example-repo", dirname: "example-repo-feat-broken" },
167
+ { repo: "shipwright", dirname: "shipwright-feat-fine" },
168
+ ]);
169
+ });
170
+
171
+ test("longest-prefix match: dashed repo names don't false-positive against a shorter prefix", async () => {
172
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
173
+ const { deps, removeCalls } = makeDeps({
174
+ dirs: ["example-repo-feat-foo"],
175
+ // "example" is a false-positive shorter prefix; "example-repo" is the real, longer match.
176
+ scopedRepos: ["example", "example-repo"],
177
+ mtimes: { "example-repo-feat-foo": oldMtime },
178
+ });
179
+
180
+ await reconcileStaleWorktrees(deps);
181
+
182
+ expect(removeCalls).toEqual([
183
+ { repo: "example-repo", dirname: "example-repo-feat-foo" },
184
+ ]);
185
+ });
186
+
187
+ test("dirname exactly equal to a scoped repo name matches", async () => {
188
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
189
+ const { deps, removeCalls } = makeDeps({
190
+ dirs: ["example-repo"],
191
+ scopedRepos: ["example-repo"],
192
+ mtimes: { "example-repo": oldMtime },
193
+ });
194
+
195
+ await reconcileStaleWorktrees(deps);
196
+
197
+ expect(removeCalls).toEqual([
198
+ { repo: "example-repo", dirname: "example-repo" },
199
+ ]);
200
+ });
201
+
202
+ test("a dirname that merely starts with a repo name's characters but not at a '-' boundary does not match", async () => {
203
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
204
+ const { deps, removeCalls } = makeDeps({
205
+ // "example-repository-feat-x" should NOT match scoped repo "example-repo" —
206
+ // the character after the prefix must be a "-" boundary (or nothing).
207
+ dirs: ["example-repository-feat-x"],
208
+ scopedRepos: ["example-repo"],
209
+ mtimes: { "example-repository-feat-x": oldMtime },
210
+ });
211
+
212
+ await reconcileStaleWorktrees(deps);
213
+
214
+ expect(removeCalls).toEqual([]);
215
+ });
216
+
217
+ test("uses the default cleanup_after_days (14) when the policy is at its default", async () => {
218
+ // 15 days old — stale under the 14-day default.
219
+ const staleUnderDefault = new Date("2026-07-13T00:00:00.000Z");
220
+ const { deps, removeCalls } = makeDeps({
221
+ dirs: ["example-repo-feat-foo"],
222
+ scopedRepos: ["example-repo"],
223
+ mtimes: { "example-repo-feat-foo": staleUnderDefault },
224
+ });
225
+
226
+ await reconcileStaleWorktrees(deps);
227
+
228
+ expect(removeCalls).toEqual([
229
+ { repo: "example-repo", dirname: "example-repo-feat-foo" },
230
+ ]);
231
+ });
232
+
233
+ test("multiple stale directories across different repos are all removed", async () => {
234
+ const oldMtime = new Date("2026-07-01T00:00:00.000Z");
235
+ const { deps, removeCalls } = makeDeps({
236
+ dirs: ["example-repo-feat-a", "shipwright-feat-b", "example-repo-fix-c"],
237
+ scopedRepos: ["example-repo", "shipwright"],
238
+ mtimes: {
239
+ "example-repo-feat-a": oldMtime,
240
+ "shipwright-feat-b": oldMtime,
241
+ "example-repo-fix-c": oldMtime,
242
+ },
243
+ });
244
+
245
+ await reconcileStaleWorktrees(deps);
246
+
247
+ expect(removeCalls).toEqual([
248
+ { repo: "example-repo", dirname: "example-repo-feat-a" },
249
+ { repo: "shipwright", dirname: "shipwright-feat-b" },
250
+ { repo: "example-repo", dirname: "example-repo-fix-c" },
251
+ ]);
252
+ });
253
+
254
+ test("a mix of stale, fresh, and unmatched directories only removes the stale+matched one", async () => {
255
+ const oldMtime = new Date("2026-07-01T00:00:00.000Z");
256
+ const recentMtime = new Date("2026-07-27T00:00:00.000Z");
257
+ const { deps, removeCalls } = makeDeps({
258
+ dirs: [
259
+ "example-repo-feat-stale",
260
+ "example-repo-feat-fresh",
261
+ "ghost-repo-feat-x",
262
+ ],
263
+ scopedRepos: ["example-repo"],
264
+ mtimes: {
265
+ "example-repo-feat-stale": oldMtime,
266
+ "example-repo-feat-fresh": recentMtime,
267
+ "ghost-repo-feat-x": oldMtime,
268
+ },
269
+ });
270
+
271
+ await reconcileStaleWorktrees(deps);
272
+
273
+ expect(removeCalls).toEqual([
274
+ { repo: "example-repo", dirname: "example-repo-feat-stale" },
275
+ ]);
276
+ });
277
+
278
+ test("RCO-1.1: an org/repo scoped entry matches its corresponding bare-name worktree dirname", async () => {
279
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
280
+ const { deps, removeCalls } = makeDeps({
281
+ dirs: ["ok-wow-agency-feat-foo"],
282
+ scopedRepos: ["ok-wow/ok-wow-agency"],
283
+ mtimes: { "ok-wow-agency-feat-foo": oldMtime },
284
+ });
285
+
286
+ await reconcileStaleWorktrees(deps);
287
+
288
+ // The bare repo name ("ok-wow-agency"), not the raw "org/repo" string, is
289
+ // what's passed to removeWorktree — mirrors pr-state-reconciler.ts's own
290
+ // splitOrgRepo(record.repo) -> shortRepo usage for the same worktree path
291
+ // shape.
292
+ expect(removeCalls).toEqual([
293
+ { repo: "ok-wow-agency", dirname: "ok-wow-agency-feat-foo" },
294
+ ]);
295
+ });
296
+
297
+ test("RCO-1.1: bare-repo-name-only scoped entries (no slash) still match directly", async () => {
298
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
299
+ const { deps, removeCalls } = makeDeps({
300
+ dirs: ["shipwright-feat-bar"],
301
+ scopedRepos: ["shipwright"],
302
+ mtimes: { "shipwright-feat-bar": oldMtime },
303
+ });
304
+
305
+ await reconcileStaleWorktrees(deps);
306
+
307
+ expect(removeCalls).toEqual([
308
+ { repo: "shipwright", dirname: "shipwright-feat-bar" },
309
+ ]);
310
+ });
311
+
312
+ test("RCO-1.1: two org/repo entries from different orgs sharing the same bare repo name are treated as ambiguous — neither is matched", async () => {
313
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
314
+ const { deps, removeCalls } = makeDeps({
315
+ dirs: ["widget-feat-foo"],
316
+ scopedRepos: ["org-a/widget", "org-b/widget"],
317
+ mtimes: { "widget-feat-foo": oldMtime },
318
+ });
319
+
320
+ await reconcileStaleWorktrees(deps);
321
+
322
+ // Ambiguous — must NOT arbitrarily pick org-a or org-b's "widget".
323
+ expect(removeCalls).toEqual([]);
324
+ });
325
+
326
+ test("RCO-1.1: longest-match-wins still applies to the normalized (bare) name when an org/repo entry and a longer bare entry both match", async () => {
327
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
328
+ const { deps, removeCalls } = makeDeps({
329
+ dirs: ["example-repo-feat-foo"],
330
+ // "org/example" normalizes to "example" — a false-positive shorter
331
+ // prefix; "example-repo" (already bare) is the real, longer match.
332
+ scopedRepos: ["org/example", "example-repo"],
333
+ mtimes: { "example-repo-feat-foo": oldMtime },
334
+ });
335
+
336
+ await reconcileStaleWorktrees(deps);
337
+
338
+ expect(removeCalls).toEqual([
339
+ { repo: "example-repo", dirname: "example-repo-feat-foo" },
340
+ ]);
341
+ });
342
+
343
+ test("getScopedRepos is invoked live on each call — a repo added between two reconcile passes is picked up on the second pass without rebuilding deps", async () => {
344
+ const oldMtime = new Date("2026-07-08T00:00:00.000Z");
345
+ // Mutable backing array, mirroring agentReposRef.get()'s live-scope semantics.
346
+ let backingScopedRepos: string[] = ["example-repo"];
347
+ const { deps, removeCalls } = makeDeps({
348
+ dirs: ["example-repo-feat-foo", "shipwright-feat-bar"],
349
+ mtimes: {
350
+ "example-repo-feat-foo": oldMtime,
351
+ "shipwright-feat-bar": oldMtime,
352
+ },
353
+ });
354
+ const liveDeps: WorktreeReaperDeps = {
355
+ ...deps,
356
+ getScopedRepos: () => backingScopedRepos,
357
+ };
358
+
359
+ // First pass: "shipwright" is not yet in scope — its worktree is skipped.
360
+ await reconcileStaleWorktrees(liveDeps);
361
+ expect(removeCalls).toEqual([
362
+ { repo: "example-repo", dirname: "example-repo-feat-foo" },
363
+ ]);
364
+
365
+ // Scope changes out-of-band (e.g. syncConfig() adds a repo) between passes.
366
+ backingScopedRepos = ["example-repo", "shipwright"];
367
+
368
+ // Second pass against the SAME deps object: the live getter must reflect
369
+ // the mutation without deps being rebuilt/memoized. (The fake
370
+ // removeWorktree doesn't actually delete anything, so
371
+ // "example-repo-feat-foo" is still stale and removed again on this pass —
372
+ // the assertion only cares that "shipwright-feat-bar" is now included.)
373
+ await reconcileStaleWorktrees(liveDeps);
374
+ expect(removeCalls).toEqual([
375
+ { repo: "example-repo", dirname: "example-repo-feat-foo" },
376
+ { repo: "example-repo", dirname: "example-repo-feat-foo" },
377
+ { repo: "shipwright", dirname: "shipwright-feat-bar" },
378
+ ]);
379
+ });
380
+ });
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @app-vitals/agent-runtime
2
-
3
- Placeholder reserving this name. Real releases are published from https://github.com/app-vitals/agent.