@yemi33/minions 0.1.2338 → 0.1.2340
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/dashboard/js/render-prs.js +3 -3
- package/docs/kb-sweep.md +26 -4
- package/docs/proposals/repo-pool-for-live-checkout.md +239 -0
- package/engine/kb-sweep.js +76 -2
- package/engine/shared.js +16 -0
- package/engine.js +75 -46
- package/package.json +1 -1
|
@@ -291,10 +291,10 @@ function prRow(pr) {
|
|
|
291
291
|
'<td><a class="pr-title" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(titleText) + '</a>' + followupChip + pausedChips + (descPreview ? '<div class="pr-desc" title="' + escapeHtml(descPreview) + '">' + escapeHtml(descPreview.length > 120 ? descPreview.slice(0, 120) + '...' : descPreview) + '</div>' : '') + '</td>' +
|
|
292
292
|
'<td><span class="pr-badge ' + statusClass + '" title="' + escapeHtml(statusLabel) + '">' + escapeHtml(statusLabel) + '</span></td>' +
|
|
293
293
|
'<td><span class="pr-badge ' + reviewClass + '" title="' + escapeHtml(reviewTitle || reviewLabel) + '">' + escapeHtml(reviewLabel) + '</span></td>' +
|
|
294
|
+
'<td><span class="pr-badge ' + buildClass + '" title="' + escapeHtml(buildTitle || buildLabel) + '">' + escapeHtml(buildLabel) + '</span></td>' +
|
|
294
295
|
'<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
|
|
295
296
|
'<td><span class="pr-agent" title="' + escapeHtml(agentText) + '">' + escapeHtml(agentText) + '</span></td>' +
|
|
296
297
|
'<td>' + reviewerCell + '</td>' +
|
|
297
|
-
'<td><span class="pr-badge ' + buildClass + '" title="' + escapeHtml(buildTitle || buildLabel) + '">' + escapeHtml(buildLabel) + '</span></td>' +
|
|
298
298
|
'<td>' + (observeBtn || '<span style="color:var(--muted);font-size:var(--text-base)">—</span>') + '</td>' +
|
|
299
299
|
'<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
|
|
300
300
|
'<td><button class="pr-info-pill" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();openPrDetail(this.dataset.prId)" title="Open PR detail (in-stack modal)">↗</button>' +
|
|
@@ -312,10 +312,10 @@ const PRS_COLGROUP =
|
|
|
312
312
|
'<col style="width:320px">' + // Title
|
|
313
313
|
'<col style="width:110px">' + // Status
|
|
314
314
|
'<col style="width:130px">' + // Review
|
|
315
|
+
'<col style="width:130px">' + // Build
|
|
315
316
|
'<col style="width:200px">' + // Branch
|
|
316
317
|
'<col style="width:140px">' + // Agent
|
|
317
318
|
'<col style="width:140px">' + // Signed Off By
|
|
318
|
-
'<col style="width:130px">' + // Build
|
|
319
319
|
'<col style="width:100px">' + // Observe
|
|
320
320
|
'<col style="width:130px">' + // Created
|
|
321
321
|
'<col style="width:80px">' + // Actions (info-pill + remove)
|
|
@@ -325,7 +325,7 @@ function prTableHtml(rows) {
|
|
|
325
325
|
return '<div class="pr-table-wrap pr-table-wrap--prs"><table class="pr-table pr-table--prs">' +
|
|
326
326
|
PRS_COLGROUP +
|
|
327
327
|
'<thead><tr>' +
|
|
328
|
-
'<th>PR</th><th>Title</th><th>Status</th><th>Review</th><th>Branch</th><th>Agent</th><th>Signed Off By</th><th>
|
|
328
|
+
'<th>PR</th><th>Title</th><th>Status</th><th>Review</th><th>Build</th><th>Branch</th><th>Agent</th><th>Signed Off By</th><th>Observe</th><th>Created</th><th></th>' +
|
|
329
329
|
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
|
330
330
|
}
|
|
331
331
|
|
package/docs/kb-sweep.md
CHANGED
|
@@ -8,10 +8,11 @@ The KB sweep is a 3-pass cycle implemented in [`engine/kb-sweep.js`](../engine/k
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
Entries in knowledge/<category>/*.md
|
|
11
|
-
→ Pass 1: Hash dedup
|
|
12
|
-
→ Pass 2: LLM batch sweep
|
|
11
|
+
→ Pass 1: Hash dedup (cheap, no LLM)
|
|
12
|
+
→ Pass 2: LLM batch sweep (Haiku, batches of 30)
|
|
13
|
+
→ Pass 2.5: Age-TTL expiry (per-category, no LLM)
|
|
13
14
|
→ Pass 3: Per-entry rewrite (Haiku, concurrency 5)
|
|
14
|
-
→ Prune _swept/ archive
|
|
15
|
+
→ Prune _swept/ archive (>30 days)
|
|
15
16
|
→ Invalidate KB cache, write engine/kb-swept.json
|
|
16
17
|
```
|
|
17
18
|
|
|
@@ -41,6 +42,26 @@ Reclassification targets are validated against `shared.KB_CATEGORIES` (`architec
|
|
|
41
42
|
|
|
42
43
|
If a batch returns invalid JSON or the runtime is unavailable, that batch is skipped with a warning and the rest of the sweep continues (source: [`engine/kb-sweep.js:139-151`](../engine/kb-sweep.js#L139)).
|
|
43
44
|
|
|
45
|
+
### Pass 2.5 — Age-Based TTL Expiry
|
|
46
|
+
|
|
47
|
+
Cheap, deterministic. No LLM call. This pass is what keeps the KB from ballooning indefinitely: the dedup and rewrite passes can only reclaim *duplicate* or *empty* entries, but the highest-volume categories (`project-notes`, `build-reports`, `reviews`) are dominated by **one-off, PR/incident-scoped notes** that are each unique by PR#/date. Nothing in Passes 1–3 can ever remove them, so without an age cutoff they accumulate forever.
|
|
48
|
+
|
|
49
|
+
For every surviving entry, the pass computes the entry's age and archives it to `knowledge/_swept/` when it exceeds that category's TTL (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_expireByAge`). Two safety properties:
|
|
50
|
+
|
|
51
|
+
- **Age is taken from the authored date** (`YYYY-MM-DD-` filename prefix / `date:` frontmatter), *not* mtime — so the Pass 3 rewrite touching a file does not reset its clock and let a stale entry live forever. When no date is parseable it falls back to mtime; when neither exists the entry is treated as fresh and never age-expired (source: [`engine/kb-sweep.js`](../engine/kb-sweep.js) `_entryAgeMs`).
|
|
52
|
+
- **Only categories with a positive TTL are eligible.** Durable categories (`architecture`, `conventions`) are absent from the default map and are never age-expired. Per-agent memory under `knowledge/agents/` is never even scanned into the manifest (it's not in `KB_CATEGORIES`), and pinned entries are excluded upstream — so none of them can be aged out.
|
|
53
|
+
|
|
54
|
+
The TTL map is `ENGINE_DEFAULTS.kbCategoryTtlDays`, overridable per-fleet via `config.engine.kbCategoryTtlDays` (merged over the defaults; set a category to `0` to disable expiry for it):
|
|
55
|
+
|
|
56
|
+
| Category | Default TTL (days) |
|
|
57
|
+
|----------|--------------------|
|
|
58
|
+
| `project-notes` | 21 |
|
|
59
|
+
| `build-reports` | 14 |
|
|
60
|
+
| `reviews` | 30 |
|
|
61
|
+
| `architecture`, `conventions` | — (never) |
|
|
62
|
+
|
|
63
|
+
Because expiry archives to `_swept/` (subject to the same 30-day retention as every other pass), an over-aggressive TTL is fully recoverable for ~30 days before the archive is pruned.
|
|
64
|
+
|
|
44
65
|
### Pass 3 — Per-Entry Rewrite
|
|
45
66
|
|
|
46
67
|
Survivors of Passes 1 and 2 that are still on disk get rewritten one entry at a time, with up to `NORMALIZE_CONCURRENCY = 5` parallel workers (source: [`engine/kb-sweep.js:26`](../engine/kb-sweep.js#L26)). Each entry is sent to Haiku with a fixed template prompt that:
|
|
@@ -145,12 +166,13 @@ The KB page is refreshed by the dashboard's main refresh loop **every third tick
|
|
|
145
166
|
hashDuplicatesArchived, // Pass 1
|
|
146
167
|
llmDuplicatesArchived, // Pass 2 (within-batch dupes)
|
|
147
168
|
staleRemoved, // Pass 2 (LLM-flagged removals)
|
|
169
|
+
ageExpired, // Pass 2.5 (per-category age-TTL archival)
|
|
148
170
|
reclassified, // Pass 2 (category moves)
|
|
149
171
|
rewritten, // Pass 3
|
|
150
172
|
rewriteBytesBefore, rewriteBytesAfter,
|
|
151
173
|
sweptArchivePruned, // Files purged from _swept/ (>30 days)
|
|
152
174
|
durationMs,
|
|
153
|
-
summary: '<n> hash-dup, <n> llm-dup, <n> stale, <n> reclassified, <n> rewritten (<bytes> saved)',
|
|
175
|
+
summary: '<n> hash-dup, <n> llm-dup, <n> stale, <n> age-expired, <n> reclassified, <n> rewritten (<bytes> saved)',
|
|
154
176
|
}
|
|
155
177
|
```
|
|
156
178
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Design review: Repo pool for live-checkout projects (multi-enlistment dispatch)
|
|
2
|
+
|
|
3
|
+
> **Review-only artifact.** This document exists so the approved implementation
|
|
4
|
+
> plan and materialized PRD for the *Repo pool for live-checkout projects*
|
|
5
|
+
> feature have a reviewable, git-tracked surface — `plans/` and `prd/` are
|
|
6
|
+
> gitignored and never committed, so there is otherwise nothing for a human to
|
|
7
|
+
> comment on before the dependent implementation work items land as code PRs.
|
|
8
|
+
> **No functional code changes accompany this file.**
|
|
9
|
+
>
|
|
10
|
+
> **Sources (do not edit — reproduced here verbatim):**
|
|
11
|
+
> - Plan: `plans/plan-w-mr3xban70004446a-2026-07-02.md` (author: Ripley, 2026-07-02)
|
|
12
|
+
> - PRD: `prd/src-2026-07-06.json` (generated by lambert, approved by calebt 2026-07-06)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Goal
|
|
17
|
+
|
|
18
|
+
Let a single logical live-checkout project (e.g. `src` / Office monorepo) be
|
|
19
|
+
backed by a **pool of live enlistment paths** instead of one `localPath`. Today
|
|
20
|
+
live mode caps mutating dispatch to **one** in-flight operation per project and
|
|
21
|
+
refuses on a dirty tree — even when the operator has a second, equally-valid
|
|
22
|
+
enlistment of the same monorepo (e.g. `C:/office/src` **and** `C:/off2/src`)
|
|
23
|
+
sitting idle. The engine has no way to know about the second checkout, so all
|
|
24
|
+
live dispatches serialize through one path.
|
|
25
|
+
|
|
26
|
+
With a pool, when a mutating work item is dispatched the engine:
|
|
27
|
+
|
|
28
|
+
1. Picks any **free** (not currently mid-dispatch) + **clean-tree** enlistment
|
|
29
|
+
from the pool.
|
|
30
|
+
2. Validates that the specific subproject/subpath the WI needs actually exists
|
|
31
|
+
in that enlistment (some enlistments may be sparse/partial checkouts).
|
|
32
|
+
3. Dispatches **in-place** in the chosen enlistment, applying today's per-path
|
|
33
|
+
live-checkout policies (dirty refusal, auto-stash, auto-reset, auto-base-repair)
|
|
34
|
+
against **that** enlistment only.
|
|
35
|
+
4. If **all** pool members are busy or invalid for the required subpath, falls
|
|
36
|
+
back to today's behavior: the WI stays pending with a `skipReason` and is
|
|
37
|
+
retried next tick (no hard failure).
|
|
38
|
+
|
|
39
|
+
Backward compatibility is a hard requirement: a project with no `livePool` (just
|
|
40
|
+
the singular `localPath`) behaves **exactly** as today. `livePool` is strictly
|
|
41
|
+
additive.
|
|
42
|
+
|
|
43
|
+
## Codebase Analysis
|
|
44
|
+
|
|
45
|
+
### How live mode works today (single path)
|
|
46
|
+
|
|
47
|
+
- **Config shape.** A project opts into live mode with `checkoutMode: 'live'`
|
|
48
|
+
and a single `localPath`. Example `src` in `config.json`:
|
|
49
|
+
`{ name:'src', localPath:'C:/office/src', checkoutMode:'live', … }`
|
|
50
|
+
(source: `config.json` → projects[] `src`).
|
|
51
|
+
- **Mode resolution.** `shared.resolveCheckoutMode(project, workItemType)`
|
|
52
|
+
returns `'live'|'worktree'`, honoring the legacy `worktreeMode` field and the
|
|
53
|
+
`liveValidation` hybrid routing (source: `engine/shared.js:2911`).
|
|
54
|
+
`shared.isLiveCheckoutProject(project)` is the convenience predicate
|
|
55
|
+
(source: `engine/shared.js:2943`).
|
|
56
|
+
- **Path resolution.** `shared.resolveSpawnPaths(project, type, minionsDir, opts)`
|
|
57
|
+
short-circuits for live mode and returns
|
|
58
|
+
`{ cwd: path.resolve(project.localPath) (+workdir), worktreeRootDir:null, liveMode:true }`
|
|
59
|
+
(source: `engine/shared.js:6355-6382`). It throws `LIVE_CHECKOUT_NO_LOCALPATH`
|
|
60
|
+
if `localPath` is falsy. **This is the single point where the enlistment path
|
|
61
|
+
is chosen** — it hard-reads `project.localPath`.
|
|
62
|
+
- **Per-project mutating-concurrency cap of 1.** The allocation loop in the
|
|
63
|
+
engine builds an in-memory `liveProjectsInUse` **Set of project names**, seeded
|
|
64
|
+
from `dispatch.active` each tick, and skips any pending mutating item whose
|
|
65
|
+
project is already in the set with `_pendingReason = 'live_checkout_busy'`
|
|
66
|
+
(source: `engine.js:10604-10613` seed, `engine.js:10767-10797` gate,
|
|
67
|
+
`engine.js:10861-10897` skip-reason annotation). Read-only types
|
|
68
|
+
(`READ_ONLY_ROOT_TASK_TYPES`) are excluded. **This cap survives restart because
|
|
69
|
+
it is derived from `dispatch.active`, which is SQL-backed** (see persistence
|
|
70
|
+
below). The pool needs this to become a **per-path** claim, not per-project.
|
|
71
|
+
- **In-place checkout + recovery policies.** `spawnAgent` (engine.js) calls
|
|
72
|
+
`engine/live-checkout.js#prepareLiveCheckout({ localPath: cwd, branchName, … })`
|
|
73
|
+
which: runs `git status --porcelain` (dirty gate), mid-operation / detached-HEAD
|
|
74
|
+
preflight, stale-base guard, and branch checkout/creation — all against the one
|
|
75
|
+
`cwd` (source: `engine.js:2453-2497`, `engine/live-checkout.js:374`). The dirty
|
|
76
|
+
recovery policies resolve per-project-then-fleet-wide:
|
|
77
|
+
- `liveCheckoutAutoStash` — `resolveLiveCheckoutAutoStash` (source: `engine/live-checkout.js:1248`), applied via `applyLiveCheckoutAutoStash` (source: `engine.js:2509`).
|
|
78
|
+
- `liveCheckoutAutoReset` — `shared.resolveLiveCheckoutAutoReset` (self-resolves by matching project on `localPath`, source: `docs/live-checkout-mode.md` §2b, `engine/live-checkout.js:332`).
|
|
79
|
+
- `liveCheckoutAutoBaseRepair` — `_defaultResolveAutoBaseRepair` (source: `engine/live-checkout.js:352`).
|
|
80
|
+
**All three resolve/match by `localPath`**, so they need to key off the
|
|
81
|
+
selected enlistment path, not the project's canonical `localPath`.
|
|
82
|
+
- **Dispatch-end restore.** `restoreLiveCheckoutAtDispatchEnd` /
|
|
83
|
+
`maybeRestoreLiveCheckoutFromRecord` put the tree back on `originalRef`
|
|
84
|
+
(source: `engine/live-checkout.js:1009, 1423`). These operate on a recorded
|
|
85
|
+
`localPath` and must record/restore the **selected** enlistment.
|
|
86
|
+
|
|
87
|
+
### Persistence (survives restart)
|
|
88
|
+
|
|
89
|
+
- Dispatch state lives in **SQLite** `engine/state.db` `dispatches` table via
|
|
90
|
+
`engine/dispatch-store.js` (`readDispatchSectioned` / `applyDispatchMutation`),
|
|
91
|
+
mirrored read-only to `dispatch.json` (source: `engine/dispatch-store.js:49,244,298`).
|
|
92
|
+
Each active dispatch is one JSON record. **Because `liveProjectsInUse` is
|
|
93
|
+
rebuilt from `dispatch.active` every tick, recording the claimed enlistment
|
|
94
|
+
path on the active dispatch record makes the per-path claim automatically
|
|
95
|
+
restart-durable** — no new table strictly required. The claim field (e.g.
|
|
96
|
+
`meta.liveEnlistmentPath`) rides on the existing record and is diffed/persisted
|
|
97
|
+
by `applyDispatchMutation` like any other field.
|
|
98
|
+
|
|
99
|
+
### Subproject / subpath declaration (already partially exists)
|
|
100
|
+
|
|
101
|
+
- `meta.workdir` is already a validated relative-POSIX subpath under the spawn
|
|
102
|
+
base; when set, the engine lands the agent at `<base>/<workdir>` and clips
|
|
103
|
+
harness discovery to that subtree (source: `engine/shared.js:6404-6416`,
|
|
104
|
+
`validateWorkItemWorkdir`). This is the natural signal for "which subpath does
|
|
105
|
+
this WI need" — subproject validation can reuse/extend it. There is also
|
|
106
|
+
`detectDominantSubproject({ projectPath, changedPaths })` used post-completion
|
|
107
|
+
(source: `engine.js:4033`).
|
|
108
|
+
|
|
109
|
+
### Dashboard / Settings surfaces (parity requirement)
|
|
110
|
+
|
|
111
|
+
- **Config-mirror API.** `dashboard.js` `mergeSettingsConfigUpdate` mirrors each
|
|
112
|
+
per-project field (`mainBranch`, `checkoutMode`, `liveCheckoutAutoStash`,
|
|
113
|
+
`liveValidation`) from the validated candidate to `current` with
|
|
114
|
+
present→copy / absent→delete semantics (source: `dashboard.js:415-467`).
|
|
115
|
+
**Any new `livePool` field must be added here or it is silently dropped.**
|
|
116
|
+
- **Settings UI.** `dashboard/js/settings.js` renders the per-project checkout-mode
|
|
117
|
+
dropdown, the `liveCheckoutAutoStash` tri-state, and `localPath` display
|
|
118
|
+
(source: `dashboard/js/settings.js:262-316`). The pool editor lives here.
|
|
119
|
+
- **Project cards.** `dashboard/js/render-other.js` renders per-project git
|
|
120
|
+
branch/dirty/state pills from `p.gitBranch/p.gitDirty/p.gitState`
|
|
121
|
+
(source: `dashboard/js/render-other.js:103-172`), fed by the per-`localPath`
|
|
122
|
+
git-status probe cache in `engine/queries.js:2512-2607` (15s TTL, keyed by
|
|
123
|
+
normalized `localPath`). Per-enlistment status reuses this probe per path.
|
|
124
|
+
- **Status payload.** `dashboard.js:2768-2785` emits `checkoutMode`,
|
|
125
|
+
`liveValidationType`, etc. per project — the place to add pool-member status.
|
|
126
|
+
|
|
127
|
+
### Tests
|
|
128
|
+
|
|
129
|
+
- Rich existing suite under `test/unit/` — `resolve-spawn-paths-live-mode.test.js`,
|
|
130
|
+
`prepare-live-checkout.test.js`, `spawn-agent-live-mode-wiring.test.js`,
|
|
131
|
+
`live-checkout-same-tick-concurrency.test.js`, `live-checkout-mutex-issue518.test.js`,
|
|
132
|
+
`live-checkout-auto-stash.test.js`, `live-checkout-auto-reset.test.js`,
|
|
133
|
+
`live-validation-config.test.js`. These are the templates for the new tests and
|
|
134
|
+
the regression guard for back-compat. `prepareLiveCheckout` and the resolvers
|
|
135
|
+
already take injectable `_git`/`_exists`/`autoReset` seams for filesystem-free
|
|
136
|
+
unit tests — the pool selector should follow the same DI style.
|
|
137
|
+
|
|
138
|
+
## Approach (scope & design)
|
|
139
|
+
|
|
140
|
+
**Core idea:** introduce a small, pure **pool-selection module** that, given a
|
|
141
|
+
project + the set of currently-claimed enlistment paths + the required subpath,
|
|
142
|
+
returns the chosen free/clean/valid enlistment (or `null`). Thread the chosen
|
|
143
|
+
path through the existing single-path machinery by treating it as the effective
|
|
144
|
+
`localPath` for that one dispatch. Everything downstream of "which path" stays
|
|
145
|
+
unchanged — it just receives the selected path instead of `project.localPath`.
|
|
146
|
+
|
|
147
|
+
Key design decisions and rationale:
|
|
148
|
+
|
|
149
|
+
1. **`livePool` is additive; `localPath` stays canonical.** New optional field
|
|
150
|
+
`project.livePool: [{ path, label?, sparseScope?: string[] }]`. When absent or
|
|
151
|
+
empty → **current behavior, unchanged** (single `localPath`). When present, the
|
|
152
|
+
effective pool is `livePool` (and `localPath` MAY be included as an implicit
|
|
153
|
+
first member for zero-config migration — decided in item 1). Rationale: keeps
|
|
154
|
+
the entire non-pool codepath byte-identical, satisfying the hard back-compat
|
|
155
|
+
requirement, and lets `resolveCheckoutMode` / `isLiveCheckoutProject` stay
|
|
156
|
+
untouched (pool is orthogonal to mode).
|
|
157
|
+
|
|
158
|
+
2. **Per-path claim replaces per-project Set.** Change the engine's
|
|
159
|
+
`liveProjectsInUse` (Set<projectName>) into a claim map keyed by
|
|
160
|
+
`projectName + '\u0000' + enlistmentPath`, seeded from `dispatch.active` by
|
|
161
|
+
reading each active dispatch's recorded `meta.liveEnlistmentPath` (falling back
|
|
162
|
+
to `project.localPath` for legacy single-path active dispatches). Rationale:
|
|
163
|
+
reuses the existing restart-durable derivation (no new SQL table), and a
|
|
164
|
+
single-path project degenerates to exactly one key = today's cap-of-1.
|
|
165
|
+
|
|
166
|
+
3. **Selection happens at allocation, is recorded on the WI/dispatch meta, and
|
|
167
|
+
is consumed by `resolveSpawnPaths`.** The allocation loop selects the
|
|
168
|
+
enlistment and stamps `item.meta.liveEnlistmentPath`; `resolveSpawnPaths`
|
|
169
|
+
(and `spawnAgent`'s `prepareLiveCheckout` call) prefer
|
|
170
|
+
`meta.liveEnlistmentPath` over `project.localPath` when set. Rationale: keeps
|
|
171
|
+
selection in the one place that already reasons about cross-tick/within-tick
|
|
172
|
+
claims (the branch-mutex + live-busy gate), and makes the claim durable via the
|
|
173
|
+
dispatch record before the agent spawns, closing the double-claim window.
|
|
174
|
+
|
|
175
|
+
4. **Clean-tree + subpath validation at selection are cheap.** Clean-tree reuses
|
|
176
|
+
the existing 15s-TTL per-path git-status probe cache (`engine/queries.js`), so
|
|
177
|
+
selection does not add a fresh `git status` per tick per path. Subpath
|
|
178
|
+
validation is a pure `fs.existsSync(<path>/<subpath>)` (or a `sparseScope`
|
|
179
|
+
prefix match when declared) — no build, no git. Rationale: selection runs every
|
|
180
|
+
tick; it must be O(cheap). The authoritative dirty gate still runs inside
|
|
181
|
+
`prepareLiveCheckout` at spawn against the **selected** path (defense in depth).
|
|
182
|
+
|
|
183
|
+
5. **Per-enlistment recovery policies.** `resolveLiveCheckoutAutoReset` /
|
|
184
|
+
`resolveLiveCheckoutAutoStash` / `resolveLiveCheckoutAutoBaseRepair` currently
|
|
185
|
+
match the project by `localPath`; they must match/apply against the **selected
|
|
186
|
+
enlistment path**. Rationale: auto-stash/reset must touch only the chosen
|
|
187
|
+
enlistment, never a sibling.
|
|
188
|
+
|
|
189
|
+
6. **Mid-dispatch subpath race fails non-retryably.** If a chosen enlistment
|
|
190
|
+
passes selection-time subpath validation but the subpath is gone by spawn
|
|
191
|
+
(manual repo edit / sparse re-checkout race), fail with a dedicated
|
|
192
|
+
`FAILURE_CLASS.LIVE_POOL_SUBPATH_MISSING` (added to `dispatch.js` `neverRetry`)
|
|
193
|
+
rather than silently re-picking mid-dispatch. Rationale: matches the existing
|
|
194
|
+
`LIVE_CHECKOUT_*` structural-failure pattern (blob-fetch, worktree-conflict) —
|
|
195
|
+
deterministic, operator-actionable, no retry storm.
|
|
196
|
+
|
|
197
|
+
7. **Selection order is deterministic: first-free-clean-valid in `livePool`
|
|
198
|
+
declaration order** (with `localPath` implicit-first if adopted). Rationale:
|
|
199
|
+
simplest correct, predictable, and directly testable; round-robin adds state
|
|
200
|
+
with no real benefit at the expected pool size (2–3). Documented explicitly so
|
|
201
|
+
behavior is a contract.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Work items (from PRD `prd/src-2026-07-06.json`)
|
|
206
|
+
|
|
207
|
+
The approved PRD breaks the feature into 10 items. `depends_on` uses the PRD item
|
|
208
|
+
ids; complexity is the PRD's `estimated_complexity`.
|
|
209
|
+
|
|
210
|
+
| # | id | Title | Type | Depends on | Complexity |
|
|
211
|
+
|---|----|-------|------|-----------|-----------|
|
|
212
|
+
| 1 | `P-a1c3f2b9` | livePool config schema + validation + normalization | implement | — | medium |
|
|
213
|
+
| 2 | `P-b7d4e0a1` | Pure pool-selection module (`engine/live-pool.js`) | implement | `P-a1c3f2b9` | medium |
|
|
214
|
+
| 3 | `P-c2e8f5d3` | Thread selected enlistment path through spawn-path resolution | implement | `P-a1c3f2b9` | medium |
|
|
215
|
+
| 4 | `P-e5a9b2c7` | Per-path claim gate in the allocation loop | implement | `P-b7d4e0a1`, `P-c2e8f5d3` | large |
|
|
216
|
+
| 5 | `P-f8b1a4d6` | Per-enlistment recovery policy resolution | implement | `P-c2e8f5d3` | medium |
|
|
217
|
+
| 6 | `P-a0d7c3e9` | Mid-dispatch subpath-missing failure class | implement | `P-c2e8f5d3`, `P-e5a9b2c7` | small |
|
|
218
|
+
| 7 | `P-d4f0b8a2` | Settings UI: pool editor + config-mirror wiring | implement | `P-a1c3f2b9` | large |
|
|
219
|
+
| 8 | `P-c9e2a6f4` | Dashboard project card: per-enlistment status | implement | `P-e5a9b2c7` | medium |
|
|
220
|
+
| 9 | `P-b3a7d1c8` | Docs + CLAUDE.md pool contract | docs | `P-a1c3f2b9`, `P-e5a9b2c7`, `P-f8b1a4d6`, `P-a0d7c3e9` | small |
|
|
221
|
+
| 10 | `P-e1c5b9a3` | Worked back-compat + example config regression test | test | `P-e5a9b2c7`, `P-f8b1a4d6` | small |
|
|
222
|
+
|
|
223
|
+
### Open questions (from the PRD)
|
|
224
|
+
|
|
225
|
+
1. **Subpath declaration source:** should the PRD add an explicit `meta.subpath`
|
|
226
|
+
field, or is the existing `meta.workdir` sufficient for the driving Loop-Android
|
|
227
|
+
use case? (Plan recommends reusing `meta.workdir`; add `meta.subpath` only if a
|
|
228
|
+
WI needs a validation subpath differing from where the agent lands.)
|
|
229
|
+
2. **localPath auto-prepend:** should a project's existing `localPath` be an
|
|
230
|
+
implicit first pool member when `livePool` is set (safest for migration), or
|
|
231
|
+
must the operator list it explicitly? (Plan recommends implicit-first + dedup —
|
|
232
|
+
this PRD assumes yes.)
|
|
233
|
+
3. **Clean-tree probe staleness:** the 15s-TTL probe could claim a path that just
|
|
234
|
+
went dirty; the authoritative gate in `prepareLiveCheckout` catches it at spawn
|
|
235
|
+
(refuse/auto-stash/auto-reset) at the cost of an occasional wasted selection.
|
|
236
|
+
Confirm this trade-off vs. a fresh probe at selection.
|
|
237
|
+
4. **Second enlistment on a different base branch:** pool selection does not
|
|
238
|
+
normalize branches across members; the existing stale-base guard applies per
|
|
239
|
+
selected path. Confirm this is acceptable operator responsibility.
|
package/engine/kb-sweep.js
CHANGED
|
@@ -83,6 +83,67 @@ function _pruneOldSwept() {
|
|
|
83
83
|
return pruned;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the effective per-category age-TTL map (category → days), merging
|
|
88
|
+
* `opts.engineConfig.kbCategoryTtlDays` over `ENGINE_DEFAULTS.kbCategoryTtlDays`.
|
|
89
|
+
* Only positive finite day-counts survive — a category set to 0, negative, or a
|
|
90
|
+
* non-number is dropped (expiry disabled for it). Durable categories that appear
|
|
91
|
+
* in neither map are simply absent → never age-expired.
|
|
92
|
+
*/
|
|
93
|
+
function _resolveTtlByCategory(opts = {}) {
|
|
94
|
+
const defaults = (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.kbCategoryTtlDays) || {};
|
|
95
|
+
const override = (opts.engineConfig && opts.engineConfig.kbCategoryTtlDays) || {};
|
|
96
|
+
const merged = { ...defaults, ...override };
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [cat, days] of Object.entries(merged)) {
|
|
99
|
+
const n = Number(days);
|
|
100
|
+
if (Number.isFinite(n) && n > 0) out[cat] = n;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Age of a manifest entry in ms. Prefers the authored date (`entry.date`, from
|
|
107
|
+
* the filename `YYYY-MM-DD-` prefix / frontmatter) so age is immune to the
|
|
108
|
+
* rewrite pass touching mtime; falls back to mtime. Unknown age → 0 (fresh,
|
|
109
|
+
* never expired) so a malformed/date-less entry is never archived by age alone.
|
|
110
|
+
*/
|
|
111
|
+
function _entryAgeMs(entry, now) {
|
|
112
|
+
const parsed = typeof entry.date === 'string' && entry.date ? Date.parse(entry.date) : NaN;
|
|
113
|
+
if (Number.isFinite(parsed)) return Math.max(0, now - parsed);
|
|
114
|
+
if (Number.isFinite(entry.mtimeMs) && entry.mtimeMs > 0) return Math.max(0, now - entry.mtimeMs);
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Age-based TTL expiry pass. Archives (to knowledge/_swept/) manifest entries in
|
|
120
|
+
* a TTL-listed category whose age exceeds that category's TTL. Durable categories
|
|
121
|
+
* (absent from the TTL map) are never touched. Pinned entries are already
|
|
122
|
+
* excluded upstream (they never enter the manifest), and knowledge/agents/ is
|
|
123
|
+
* never scanned into the manifest at all — so per-agent memory is inherently safe.
|
|
124
|
+
*
|
|
125
|
+
* @returns {{ expired:number, survivors:object[] }}
|
|
126
|
+
*/
|
|
127
|
+
function _expireByAge(manifest, opts = {}) {
|
|
128
|
+
const ttlByCategory = _resolveTtlByCategory(opts);
|
|
129
|
+
if (Object.keys(ttlByCategory).length === 0) return { expired: 0, survivors: manifest.slice() };
|
|
130
|
+
const now = Number(opts.now) || Date.now();
|
|
131
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
132
|
+
let expired = 0;
|
|
133
|
+
const survivors = [];
|
|
134
|
+
for (const e of manifest) {
|
|
135
|
+
const ttlDays = ttlByCategory[e.category];
|
|
136
|
+
if (!ttlDays) { survivors.push(e); continue; }
|
|
137
|
+
if (_entryAgeMs(e, now) <= ttlDays * DAY_MS) { survivors.push(e); continue; }
|
|
138
|
+
const fp = path.join(KB_DIR, e.category, e.file);
|
|
139
|
+
if (!fs.existsSync(fp)) continue; // already removed by an earlier pass
|
|
140
|
+
if (opts.dryRun) { expired++; continue; }
|
|
141
|
+
if (_archiveKbFile(fp, `age-expired: ${e.category} entry older than ${ttlDays}d`)) expired++;
|
|
142
|
+
else survivors.push(e); // archive failed — keep it visible
|
|
143
|
+
}
|
|
144
|
+
return { expired, survivors };
|
|
145
|
+
}
|
|
146
|
+
|
|
86
147
|
/** Group entries by content hash, keep most-recent per group. Cheap, no LLM. */
|
|
87
148
|
function _hashDedup(manifest, opts = {}) {
|
|
88
149
|
const groups = new Map(); // hash → entries[]
|
|
@@ -478,6 +539,7 @@ async function _runKbSweepImpl(opts = {}) {
|
|
|
478
539
|
hashDuplicatesArchived: 0,
|
|
479
540
|
llmDuplicatesArchived: 0,
|
|
480
541
|
staleRemoved: 0,
|
|
542
|
+
ageExpired: 0,
|
|
481
543
|
reclassified: 0,
|
|
482
544
|
rewritten: 0,
|
|
483
545
|
rewriteBytesBefore: 0,
|
|
@@ -526,8 +588,17 @@ async function _runKbSweepImpl(opts = {}) {
|
|
|
526
588
|
summary.staleRemoved = llmActions.removed;
|
|
527
589
|
summary.reclassified = llmActions.reclassified;
|
|
528
590
|
|
|
591
|
+
// 2.5. Age-based TTL expiry (W-mr48yth5) — archive transient-category entries
|
|
592
|
+
// older than their per-category TTL to knowledge/_swept/. This is the pass that
|
|
593
|
+
// keeps PR/incident-scoped one-off notes (project-notes, build-reports, reviews)
|
|
594
|
+
// from accumulating indefinitely — dedup/rewrite can't reclaim them because each
|
|
595
|
+
// is unique by PR#/date. Durable categories (no TTL) are untouched.
|
|
596
|
+
const survivingAfterLlm = afterHash.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
|
|
597
|
+
const ageResult = _expireByAge(survivingAfterLlm, opts);
|
|
598
|
+
summary.ageExpired = ageResult.expired;
|
|
599
|
+
|
|
529
600
|
// 3. Per-entry rewrite (compress + normalize)
|
|
530
|
-
// Filter to entries that survived hash + LLM passes (still on disk)
|
|
601
|
+
// Filter to entries that survived hash + LLM + age passes (still on disk)
|
|
531
602
|
const stillOnDisk = afterHash.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
|
|
532
603
|
const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
|
|
533
604
|
summary.rewritten = rewriteResult.processed;
|
|
@@ -549,7 +620,7 @@ async function _runKbSweepImpl(opts = {}) {
|
|
|
549
620
|
}
|
|
550
621
|
|
|
551
622
|
summary.durationMs = Date.now() - t0;
|
|
552
|
-
summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
|
|
623
|
+
summary.summary = `${summary.hashDuplicatesArchived} hash-dup, ${summary.llmDuplicatesArchived} llm-dup, ${summary.staleRemoved} stale, ${summary.ageExpired} age-expired, ${summary.reclassified} reclassified, ${summary.rewritten} rewritten (${(summary.bytesBefore - summary.bytesAfter).toLocaleString()} bytes saved)`;
|
|
553
624
|
|
|
554
625
|
if (!opts.dryRun) {
|
|
555
626
|
try { shared.mutateKbSwept(() => ({ timestamp: ts(), summary: summary.summary, detail: summary })); } catch { /* ignore */ }
|
|
@@ -706,6 +777,9 @@ module.exports = {
|
|
|
706
777
|
_parseFrontmatter,
|
|
707
778
|
_serializeFrontmatter,
|
|
708
779
|
_hashDedup,
|
|
780
|
+
_expireByAge,
|
|
781
|
+
_entryAgeMs,
|
|
782
|
+
_resolveTtlByCategory,
|
|
709
783
|
_archiveKbFile,
|
|
710
784
|
COMPRESS_THRESHOLD_BYTES,
|
|
711
785
|
SWEPT_FLAG_KEY,
|
package/engine/shared.js
CHANGED
|
@@ -3155,6 +3155,22 @@ const ENGINE_DEFAULTS = {
|
|
|
3155
3155
|
// & Review Loop, or set `engine.autoFixPaused: true` in config.json.
|
|
3156
3156
|
autoFixPaused: false, // hard-stop kill-switch / master override (see comment above)
|
|
3157
3157
|
autoConsolidateMemory: false, // opt-in: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Inbox→notes consolidation already runs every tick via consolidateInbox; this flag only controls the KB sweep.
|
|
3158
|
+
// W-mr48yth5 — per-category age-based TTL (in days) for the KB sweep's expiry
|
|
3159
|
+
// pass. During runKbSweep, an entry in a listed category whose authored date
|
|
3160
|
+
// (filename `YYYY-MM-DD-` prefix, falling back to file mtime) is older than
|
|
3161
|
+
// its TTL is archived to knowledge/_swept/ (recoverable — pruned after 30d by
|
|
3162
|
+
// the existing _pruneOldSwept pass). Transient, PR/incident-scoped categories
|
|
3163
|
+
// accumulate one-off notes that hash-dedup / LLM-dedup / rewrite can never
|
|
3164
|
+
// reclaim (each is unique by PR#/date), so they get finite TTLs; durable
|
|
3165
|
+
// categories (architecture, conventions) and per-agent memory (knowledge/agents/,
|
|
3166
|
+
// never scanned by the sweep) are intentionally omitted → never age-expired.
|
|
3167
|
+
// config.engine.kbCategoryTtlDays is merged over these defaults; set a category
|
|
3168
|
+
// to 0 (or omit it) to disable expiry for that category.
|
|
3169
|
+
kbCategoryTtlDays: {
|
|
3170
|
+
'project-notes': 21,
|
|
3171
|
+
'build-reports': 14,
|
|
3172
|
+
'reviews': 30,
|
|
3173
|
+
},
|
|
3158
3174
|
// W-mqtvnnj1000357fa — fleet-wide fallback for live-checkout auto-stash. When
|
|
3159
3175
|
// true, a dirty live-checkout tree is `git stash push --include-untracked`'d
|
|
3160
3176
|
// before dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY,
|
package/engine.js
CHANGED
|
@@ -10645,55 +10645,84 @@ async function tickInner() {
|
|
|
10645
10645
|
|
|
10646
10646
|
// Dispatch items — spawnAgent moves each from pending→active on disk.
|
|
10647
10647
|
// We use the already-loaded item objects; spawnAgent handles the state transition.
|
|
10648
|
+
//
|
|
10649
|
+
// W-mr9o1oe0001c4601 — Spawn all claimed items for this tick CONCURRENTLY
|
|
10650
|
+
// rather than awaiting each spawnAgent serially. `spawnAgent` does real
|
|
10651
|
+
// wall-clock work per item (prompt build + `git worktree add`, which on a
|
|
10652
|
+
// ~250k-file monorepo can take multiple seconds), so the old sequential loop
|
|
10653
|
+
// made eager routing (engine/routing.js already fans work across every idle
|
|
10654
|
+
// agent in a single allocation pass) visibly "trickle in" one agent at a time,
|
|
10655
|
+
// contradicting the stated eager-dispatch design. `toDispatch` is already
|
|
10656
|
+
// bounded by `slotsAvailable` (≤ maxConcurrent — the seed loop above decrements
|
|
10657
|
+
// `generalSlots` and stops pushing at 0), so an unbounded Promise.allSettled
|
|
10658
|
+
// here is naturally capped; no dedicated spawn-concurrency knob is needed.
|
|
10659
|
+
// Concurrent `git worktree add` against the same repo is safe on this codebase:
|
|
10660
|
+
// each worktree gets its own `.git/worktrees/<name>` metadata dir, and
|
|
10661
|
+
// runWorktreeAdd already retries the exact lock-contention errors
|
|
10662
|
+
// (`index.lock`, `could not lock`, `resource busy`, `already exists`) that
|
|
10663
|
+
// concurrent adds can transiently produce. The per-project live-checkout gate
|
|
10664
|
+
// (liveProjectsInUse) already caps live-mode projects to one mutating dispatch,
|
|
10665
|
+
// so concurrent in-place writes to one operator checkout cannot happen here.
|
|
10648
10666
|
const dispatched = new Set();
|
|
10649
|
-
|
|
10650
|
-
|
|
10651
|
-
|
|
10652
|
-
|
|
10653
|
-
|
|
10654
|
-
|
|
10655
|
-
|
|
10656
|
-
|
|
10657
|
-
|
|
10658
|
-
|
|
10659
|
-
|
|
10660
|
-
|
|
10661
|
-
|
|
10662
|
-
|
|
10663
|
-
|
|
10664
|
-
|
|
10665
|
-
|
|
10666
|
-
|
|
10667
|
-
|
|
10668
|
-
|
|
10669
|
-
|
|
10670
|
-
|
|
10671
|
-
|
|
10672
|
-
|
|
10673
|
-
|
|
10674
|
-
|
|
10675
|
-
|
|
10676
|
-
|
|
10677
|
-
|
|
10678
|
-
|
|
10679
|
-
|
|
10680
|
-
|
|
10681
|
-
|
|
10682
|
-
|
|
10683
|
-
|
|
10684
|
-
|
|
10685
|
-
|
|
10686
|
-
|
|
10687
|
-
|
|
10688
|
-
|
|
10689
|
-
|
|
10690
|
-
|
|
10691
|
-
|
|
10692
|
-
|
|
10693
|
-
|
|
10667
|
+
const spawnOneItem = async (item) => {
|
|
10668
|
+
// Dedup by id (toDispatch is already deduped by seenPendingIds — defensive).
|
|
10669
|
+
if (dispatched.has(item.id)) return;
|
|
10670
|
+
// Staleness: if a newer tick force-released this one while we were building
|
|
10671
|
+
// the batch, don't spawn a stale tick's agents.
|
|
10672
|
+
if (_isTickStale(myGeneration)) return;
|
|
10673
|
+
let proc;
|
|
10674
|
+
try { proc = await spawnAgent(item, config); } catch (spawnErr) {
|
|
10675
|
+
log('error', `spawnAgent exception for ${item.id}: ${spawnErr.message}`);
|
|
10676
|
+
proc = null;
|
|
10677
|
+
}
|
|
10678
|
+
// A newer tick may have superseded us mid-spawn — don't mutate shared state.
|
|
10679
|
+
if (_isTickStale(myGeneration)) return;
|
|
10680
|
+
if (proc === null) {
|
|
10681
|
+
// spawnAgent failed (e.g., worktree creation error). It already called
|
|
10682
|
+
// completeDispatch internally which handles retry logic, but log at the
|
|
10683
|
+
// dispatch-loop level for visibility and handle any edge cases where
|
|
10684
|
+
// completeDispatch wasn't called.
|
|
10685
|
+
log('error', `spawnAgent returned null for ${item.id} (${item.type} → ${item.agent}) — spawn failed`);
|
|
10686
|
+
// Defensive: ensure the work item is re-queued if completeDispatch didn't fire
|
|
10687
|
+
if (item.meta?.item?.id) {
|
|
10688
|
+
try {
|
|
10689
|
+
const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
|
|
10690
|
+
? path.join(ENGINE_DIR, '..', 'work-items.json')
|
|
10691
|
+
: item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
|
|
10692
|
+
if (wiPath) {
|
|
10693
|
+
if (_isTickStale(myGeneration)) return;
|
|
10694
|
+
mutateWorkItems(wiPath, items => {
|
|
10695
|
+
const wi = items.find(i => i.id === item.meta.item.id);
|
|
10696
|
+
if (wi && wi.status === WI_STATUS.DISPATCHED) {
|
|
10697
|
+
// completeDispatch didn't update the work item — re-queue manually
|
|
10698
|
+
wi.status = WI_STATUS.PENDING;
|
|
10699
|
+
wi._retryCount = (wi._retryCount || 0) + 1;
|
|
10700
|
+
wi._lastRetryReason = 'spawnAgent returned null';
|
|
10701
|
+
wi._lastRetryAt = ts();
|
|
10702
|
+
delete wi.dispatched_at;
|
|
10703
|
+
delete wi.dispatched_to;
|
|
10704
|
+
log('info', `Re-queued ${item.meta.item.id} as pending (retry ${wi._retryCount})`);
|
|
10705
|
+
}
|
|
10706
|
+
});
|
|
10707
|
+
}
|
|
10708
|
+
} catch (e) { log('warn', `Failed to re-queue work item after spawn failure: ${e.message}`); }
|
|
10709
|
+
}
|
|
10710
|
+
} else {
|
|
10711
|
+
dispatched.add(item.id);
|
|
10712
|
+
// Sync PRD item status after successful spawn (#480)
|
|
10713
|
+
if (item.meta?.item?.sourcePlan) {
|
|
10714
|
+
try { syncPrdItemStatus(item.meta.item.id, WI_STATUS.DISPATCHED, item.meta.item.sourcePlan); }
|
|
10715
|
+
catch (e) { log('warn', `prd sync after spawn: ${e.message}`); }
|
|
10694
10716
|
}
|
|
10695
10717
|
}
|
|
10696
|
-
}
|
|
10718
|
+
};
|
|
10719
|
+
// Promise.allSettled: one item's spawn failure never affects another's, and
|
|
10720
|
+
// each spawnOneItem already swallows its own errors (per-item try/catch), so a
|
|
10721
|
+
// rejected task here is not expected — allSettled is belt-and-suspenders.
|
|
10722
|
+
await Promise.allSettled(toDispatch.map((item) => spawnOneItem(item)));
|
|
10723
|
+
// A force-release may have superseded this tick while spawns were in flight —
|
|
10724
|
+
// abort before the downstream skip-reason annotation reads/mutates shared state.
|
|
10725
|
+
if (_isTickStale(myGeneration)) return;
|
|
10697
10726
|
|
|
10698
10727
|
// Annotate remaining pending items with skipReason so dashboard can show why they're waiting.
|
|
10699
10728
|
// Re-read dispatch after spawns (spawnAgent moves items from pending→active).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2340",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|