Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
d028fc9
WL-0MSRLH52R003CK49: Implementation complete
Aug 25, 2026
6261a1c
WL-0MT5J1CC4007QJFP: Implementation complete
Aug 25, 2026
b9d2745
WL-0MT5L3BGA001MKOP: Implementation complete
Aug 25, 2026
09df9b2
WL-0MT5J3X1R009A6I6: Implementation complete
Aug 25, 2026
e6ae601
WL-0MT5J4Q290025O0L: Implementation complete
Aug 25, 2026
4ee58c3
WL-0MT5L55AQ002WTGQ: Implementation complete
Aug 25, 2026
2a29015
WL-0MT5L6G88006N4WD: Implementation complete
Aug 25, 2026
eb66c67
WL-0MT5L6TLQ004NDLK: Implementation complete
Aug 25, 2026
8e5942d
WL-0MSNIX4V60012266: Compress metadata panel space in herdr plugin
Aug 26, 2026
6ea8b67
fix: refresh selection list after close/delete/update shortcuts (WL-0…
Aug 26, 2026
1f05039
WL-0MT9FL94000383Q5: Per-slot operator gate unit-test suite (test-first)
Aug 26, 2026
684b2a5
fix: refresh selection list after close/delete/update shortcuts (WL-0…
Aug 26, 2026
6d9087d
WL-0MT9FL9JO002JEAZ: Implementation complete
Aug 26, 2026
b67d750
Merge branch 'dev' of github.com:TheWizardsCode/ContextHub into dev
Aug 26, 2026
b776b93
Merge branch 'dev' of github.com:TheWizardsCode/ContextHub into dev
Aug 26, 2026
633d7bd
WL-0MT9FL94000383Q5: Add non-finite slot_id fixtures to AC6 malformed…
Aug 26, 2026
ebf57e5
fix: route data-modifying wl commands through dispatchChordCommand (W…
Aug 26, 2026
74c2a5f
WL-0MSOYWZTH003JFVD: Implementation complete
Aug 27, 2026
b2be14b
WL-0MT9XRZDK006GMUH: Implementation complete
Aug 27, 2026
bf36f85
WL-0MT47BMR7003ZQ66: Implementation complete
Aug 27, 2026
b3f5e75
WL-0MTBOXEGR009KDP6: Implementation complete
Aug 28, 2026
cdfa8de
WL-0MT9ZJF28004UJ28: Implementation complete
Aug 28, 2026
669eab4
docs: update scheduled-prompt id to use full command (Downtime pane n…
Aug 28, 2026
2da2e90
WL-0MTBWK01P000QO90: DB-change gate tests + db-change module + maxSyn…
Aug 28, 2026
1648d1a
Merge WL-0MTBWK01P000QO90: DB-change gate tests + db-change module + …
Aug 28, 2026
e59daa0
WL-0MTBWPGY2003KPFQ: Implementation complete
Aug 28, 2026
b68b211
WL-0MTBWPHDN0089J1X: Implementation complete
Aug 29, 2026
5e0db89
WL-0MT32F4SD003KC2V: Implementation complete
Aug 29, 2026
66e4b8c
herdr: pane titles reflect how they were spawned (WL-0MSJ4E8UA005KG9Y)
Aug 29, 2026
1cb934a
chore: gitignore .herdr-downtime-disabled runtime marker
Aug 29, 2026
e842208
WL-0MTEZ4XZJ006Y9U7: Implementation complete
Aug 30, 2026
77c9741
WL-0MTEZ4XZJ006Y9U7: Implementation complete
Aug 30, 2026
382e8e4
fix(herdr): remove noisy console output when scheduled-prompts config…
Aug 30, 2026
9228a9f
WL-0MT8KSTOE00871E7: Implementation complete
Aug 30, 2026
4d917f2
feat(tui): classify 'All providers exhausted' as SERVER_ERROR for aut…
Aug 30, 2026
1cf2557
WL-0MTBB8I4E008FJCC: verify/standardize batch close with multiple IDs
Aug 30, 2026
6eefd28
WL-0MTF4A4V3004HS7J: cooldown-lease lifecycle unit-test suite (test-f…
Aug 30, 2026
b0e73d6
WL-0MTF4A4V50040AQ2: unit tests for self-healing refreshLease (test-f…
Aug 30, 2026
b6d73e8
WL-0MTF4A4V90055K7P: zombie-in-cooldown integration regression (test-…
Aug 30, 2026
bb81b12
WL-0MTF4A4V50040AQ2: unit tests for self-healing refreshLease (test-f…
Aug 30, 2026
6611bce
WL-0MTF4ABQL0076X0F: self-healing refreshLease in leader-election.ts
Aug 30, 2026
eb0a306
WL-0MTF4A4V3004HS7J: cooldown-lease lifecycle unit-test suite (test-f…
Aug 30, 2026
b629327
WL-0MTF4A4V50040AQ2: unit tests for self-healing refreshLease (test-f…
Aug 30, 2026
ea76b95
WL-0MTF4A4V90055K7P: zombie-in-cooldown integration regression (test-…
Aug 30, 2026
5aaab27
WL-0MTF4ABQL0076X0F: self-healing refreshLease in leader-election.ts
Aug 30, 2026
0b65bc4
WL-0MTF4ABQN0043F4V: per-tick leader re-derivation + self-heal in tick()
Aug 30, 2026
8155fc3
WL-0MTF4ABQQ0037BF7: docs — zombie-leader lease lifecycle (F6)
Aug 30, 2026
83410be
chore: initial
Aug 31, 2026
ec6286c
fix(sync): detect watermark divergence and force full snapshot export…
Aug 31, 2026
9e8ca64
WL-0MTFNII4X00632CG,F2,F3,F4: Fix sync-conflict loop and attribute re…
Aug 31, 2026
b97fbf2
WL-0MTH1HTTG0033JM9: gate sync divergence detection on dirty records …
Aug 31, 2026
6b2812c
WL-0MTH7G2O1004BHN5: boost audit-backed items in wl next sorting
Aug 31, 2026
bf8a482
WL-0MTCP0ILG008YV84: Implementation complete
Aug 31, 2026
54dea5b
WL-0MTH7G2O1004BHN5: Implementation complete
Aug 31, 2026
76e36d3
Merge branch 'wl-WL-0MTF4A4V3004HS7J-cooldown-lease-tests-maintenance…
Aug 31, 2026
8b8a849
Merge branch 'wl-WL-0MTF4A4V50040AQ2-unit-tests-self-healing-refreshl…
Aug 31, 2026
2370454
Merge branch 'wl-WL-0MTF4A4V90055K7P-zombie-dual-instance-regression-…
Aug 31, 2026
15fc497
Merge branch 'wl-WL-0MTF4ABQL0076X0F-self-healing-refreshlease-in-lea…
Aug 31, 2026
a962edb
Bump version to v1.0.13
Aug 31, 2026
a631111
Update CHANGELOG.md for v1.0.13
Aug 31, 2026
6555460
Merge origin/dev into main (automated)
Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Logs
logs
*.log

# Runtime state: downtime-dispatch disable marker (env-specific, operator-toggled)
.herdr-downtime-disabled
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Changelog

## v1.0.13 (2026-09-01)

## v1.0.12 (2026-08-25)
### Features
- Herdr downtime dispatcher: scheduled prompts (best-effort frequency) config (WL-0MSS1Q5ER007QDKX)
Expand Down
9 changes: 5 additions & 4 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,10 +536,11 @@ The `--recency-policy` flag controls how recently updated items are weighted dur

When multiple candidate items exist, `wl next` ranks them using the following criteria (highest weight first):

1. **Priority** — higher-priority items always rank above lower-priority items.
2. **Blocks high-priority work** — among equal-priority candidates, an item that is a prerequisite for a `high` or `critical` downstream item is preferred. This ensures that unblocking high-value work takes precedence over unrelated tasks at the same priority.
3. **Blocked penalty** — items with active dependency blockers are excluded by default (see `--include-blocked`).
4. **Tie-breakers** — sort_index, then age (older items first) break remaining ties.
1. **Priority** — higher-priority items always rank above lower-priority items. **`critical` is a hard top tier that no other boost can overtake.**
2. **Audit-not-ready tier (WL-0MTH7G2O1004BHN5)** — any non-critical item with a **fresh** audit (`readyToClose === false` and `auditedAt >= updatedAt`) that is not ready to close is ranked **ahead of every unaudited / ready-to-close / stale non-critical item**, regardless of base priority (high/medium/low) and regardless of age/effort/recency. The tier is second only to `critical`. Missing audit, `readyToClose === true`, or stale (`auditedAt < updatedAt`) audits receive no boost. The same tier governs `wl re-sort`/`reSort` and `wl next --no-re-sort` (via the `selectBySortIndex` stale-index prefix) and `wl next --json` ordering.
3. **Blocks high-priority work** — among equal-tier candidates, an item that is a prerequisite for a `high` or `critical` downstream item is preferred. This ensures that unblocking high-value work takes precedence over unrelated tasks at the same priority.
4. **Blocked penalty** — items with active dependency blockers are excluded by default (see `--include-blocked`).
5. **Tie-breakers** — sort_index, then age (older items first) break remaining ties.

Items with `status: 'blocked'` that have `critical` priority trigger a special escalation path: their direct blockers are surfaced immediately, bypassing the general ranking logic. Blocked `critical` items that are children of an open parent are still escalated — the parent item's blockers will be surfaced if the critical child is in its tree. Child blockers are never returned directly (see "Hierarchy-aware selection" above).

Expand Down
8 changes: 5 additions & 3 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The base set provisioned on a fresh init:
{
"entries": [
{
"id": "refactor",
"id": "/skill:refactor",
"prompt": "/skill:refactor",
"intervalDays": 3,
"lastTriggeredAt": null
Expand All @@ -66,8 +66,10 @@ The base set provisioned on a fresh init:

Each entry carries:

- `id` — stable entry id (used for the pane name `Downtime <id>` and the
rolling dispatch-log marker),
- `id` — stable entry id (used for the pane name `Downtime <id>` — set it
to the command/skill itself, e.g. `/skill:refactor`, so the pane name
clearly identifies the scheduled command — and the rolling dispatch-log
marker),
- `prompt` — any text the pi agent pane can run (e.g. `/skill:refactor`),
- `intervalDays` — best-effort frequency in whole days (a delayed dispatch
never fires more often than the frequency),
Expand Down
340 changes: 340 additions & 0 deletions bench/wal-read-latency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
/**
* Benchmark: impact of a large WAL on read latency.
*
* Work item: WL-0MT5J3X1R009A6I6 — Measure read latency impact of large WAL
*
* Reproduces the scenario behind the observed 16MB `worklog.db-wal` under
* concurrent writer load (WL-0MSG8EG7P002MX2I): a writer whose checkpointing
* cannot keep up (simulated deterministically with `wal_autocheckpoint = 0` on
* the writer connection) accumulates WAL frames far past SQLite's default
* 1000-page (~4MB) threshold. Read queries executed while the large WAL exists
* must scan the WAL for the latest version of every page; after a
* `PRAGMA wal_checkpoint(TRUNCATE)` those pages live in the main DB file.
*
* Usage:
* npx tsx bench/wal-read-latency.ts # run benchmark, text output
* npx tsx bench/wal-read-latency.ts --json # machine-readable output
*
* Options:
* --items N work items to create (default 3000)
* --desc-kb N item description size in KiB (default 2)
* --wal-target-mb target WAL size in MiB (default 16, matching observed)
* --iterations query repetitions per phase (default 7, median reported)
* --keep keep temp artifacts (default false)
* --json JSON output for agent consumption
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pathToFileURL } from 'url';
import Database from 'better-sqlite3';
import { SqlitePersistentStore } from '../src/persistent-store.js';
import type { WorkItem } from '../src/types.js';

interface Options {
items: number;
walTargetMb: number;
iterations: number;
descKb: number;
keep: boolean;
json: boolean;
}

const DEFAULTS: Options = {
items: 3000,
walTargetMb: 16,
iterations: 7,
descKb: 2,
keep: false,
json: false,
};

function parseArgs(argv: string[]): Options {
const options = { ...DEFAULTS };
const args = argv.slice(2);
const readValue = (index: number): string | undefined => {
const value = args[index + 1];
if (!value || value.startsWith('--')) return undefined;
return value;
};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
switch (arg) {
case '--items': {
const value = readValue(i);
if (value) options.items = Number(value);
break;
}
case '--wal-target-mb': {
const value = readValue(i);
if (value) options.walTargetMb = Number(value);
break;
}
case '--iterations': {
const value = readValue(i);
if (value) options.iterations = Number(value);
break;
}
case '--desc-kb': {
const value = readValue(i);
if (value) options.descKb = Number(value);
break;
}
case '--keep':
options.keep = true;
break;
case '--json':
options.json = true;
break;
default:
break;
}
}
return options;
}

// ── DB helpers ──────────────────────────────────────────────────────

function createTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}

function walSizeBytes(dbPath: string): number {
const walPath = `${dbPath}-wal`;
try {
return fs.statSync(walPath).size;
} catch {
return 0;
}
}

function makeWorkItem(index: number, items: number, descKb: number): WorkItem {
// Realistic content: title + description with enough text that rows span
// multiple 4KB pages (Worklog item descriptions are multi-KB markdown).
const desc = `Work item ${index} of ${items}. ${'lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. '.repeat(Math.max(8, (descKb * 1024) / 124))}`;
const id = `BENCH-${String(index).padStart(8, '0').toUpperCase()}`;
return {
id,
title: `Benchmark work item ${index}`,
description: desc,
status: 'open',
priority: 'medium',
sortIndex: (index + 1) * 10,
parentId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: ['bench'],
assignee: '',
stage: 'intake_complete',
issueType: 'feature',
createdBy: 'benchmark',
deletedBy: '',
deleteReason: '',
risk: 'Low' as const,
effort: 'S' as const,
};
}

// ── Measurements ────────────────────────────────────────────────────

function medianMs(rows: number[]): number {
const sorted = [...rows].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
}

/**
* Run the exact read SQL mirrors of the store's hot paths on a read-only
* connection. Mirrors:
* - PersistentStore.getAllWorkItems: SELECT * FROM workitems
* - PersistentStore.getWorkItem(id): SELECT * FROM workitems WHERE id = ?
* - searchFts: SELECT ... FROM worklog_fts WHERE worklog_fts MATCH ?
*/
function measurePhase(dbPath: string, iterations: number, itemIds: string[]): {
getAllMedianMs: number;
getByIdMedianMs: number;
searchMedianMs: number;
} {
const getAll: number[] = [];
const getById: number[] = [];
const search: number[] = [];

for (let i = 0; i < iterations; i += 1) {
// One fresh read-only connection per iteration (no schema-init writes,
// so the measurement isolates WAL-scan cost without triggering
// auto-checkpoint from the reader). A small SQLite page cache keeps the
// full-table scan disk/WAL-bound so the WAL effect is not masked by a
// warm per-connection cache holding the whole table.
const conn = new Database(dbPath, { readonly: true });
conn.pragma('cache_size = -2048'); // 2MiB
try {
const getAllStmt = conn.prepare('SELECT * FROM workitems');
const getAllStart = process.hrtime.bigint();
getAllStmt.all();
getAll.push(Number(process.hrtime.bigint() - getAllStart) / 1e6);

const getByIdStmt = conn.prepare('SELECT * FROM workitems WHERE id = ?');
const id = itemIds[i % itemIds.length];
const getByIdStart = process.hrtime.bigint();
getByIdStmt.get(id);
getById.push(Number(process.hrtime.bigint() - getByIdStart) / 1e6);

const searchStmt = conn.prepare(
"SELECT * FROM worklog_fts WHERE worklog_fts MATCH 'benchmark*' LIMIT 50"
);
const searchStart = process.hrtime.bigint();
searchStmt.all();
search.push(Number(process.hrtime.bigint() - searchStart) / 1e6);
} finally {
conn.close();
}
}

return {
getAllMedianMs: medianMs(getAll),
getByIdMedianMs: medianMs(getById),
searchMedianMs: medianMs(search),
};
}

function pct(largeMs: number, smallMs: number): number {
if (smallMs <= 0) return 0;
return ((largeMs - smallMs) / smallMs) * 100;
}

// ── Benchmark runner (exported for tests / CLI) ────────────────────

export interface WalReadLatencyResult {
items: number;
walTargetMiB: number;
iterations: number;
walSizeLargeMiB: number;
walCheckpoint: unknown[];
phaseResults: Array<{
phase: string;
walMiB: number;
getAllMedianMs: number;
getByIdMedianMs: number;
searchMedianMs: number;
}>;
delta: { getAllSlowerPct: number; getByIdSlowerPct: number; searchSlowerPct: number };
dbPath: string;
}

/**
* Run the full benchmark: build a temp worklog DB (real schema), grow a
* large WAL on a writer connection with auto-checkpoint disabled, measure
* reads, `wal_checkpoint(TRUNCATE)`, measure reads again. Returns the
* summary (and removes the temp dir unless `--keep`).
*/
export function runBenchmark(argv: string[]): WalReadLatencyResult {
const options = parseArgs(argv);
const tempDir = createTempDir('wl-wal-bench-');
const dbPath = path.join(tempDir, 'worklog.db');

try {
// 1. Create the real schema (store ctor runs schema init + WAL pragmas).
const store = new SqlitePersistentStore(dbPath);
const items = Array.from({ length: options.items }, (_, i) => makeWorkItem(i, options.items, options.descKb));
store.importData(items, []);
// Compact: baseline data is small and fully checkpointed on the way out.
store.close();

const itemIds = items.map((i) => i.id);

// 2. Simulate "checkpointing cannot keep up": a writer connection with
// auto-checkpoint disabled bloats the WAL to the target size.
const writer = new Database(dbPath);
// Disable automatic checkpointing on the writer so WAL frames genuinely
// accumulate (mirrors the spawn-storm case where concurrent writers
// outpace any single connection's between-transaction checkpoint).
writer.pragma('wal_autocheckpoint = 0');
const walTargetBytes = options.walTargetMb * 1024 * 1024;
const bloatStmt = writer.prepare('UPDATE workitems SET description = ? WHERE id = ?');
// Content must CHANGE every write: SQLite treats an UPDATE that sets the
// same value as a no-op (no page rewrite, no WAL frame).
let bloatSeq = 0;
const nextBloat = () => `bloat-${bloatSeq++}: ${'x'.repeat(8 * 1024)}`;
// Bloat in small committed batches and check the WAL size after each
// commit — the on-disk WAL size only reflects committed frames, so
// checking inside one giant transaction gives stale sizes.
const BATCH = 64;
while (walSizeBytes(dbPath) < walTargetBytes) {
const batch = itemIds.slice(0, Math.min(BATCH, itemIds.length));
const tx = writer.transaction(() => {
for (const id of batch) bloatStmt.run(nextBloat(), id);
});
tx();
}
const walMb = walSizeBytes(dbPath) / (1024 * 1024);

// 3. Measure reads while the large WAL exists (writer stays open so no
// checkpoint-on-close fires mid-measurement).
const before = measurePhase(dbPath, options.iterations, itemIds);

// 4. Force a TRUNCATE checkpoint (merges WAL into the main DB file).
const checkpoint = writer.pragma('wal_checkpoint(TRUNCATE)') as unknown[];
writer.close();

// 5. Measure the same reads again with the WAL fully merged.
const after = measurePhase(dbPath, options.iterations, itemIds);

return {
items: options.items,
walTargetMiB: options.walTargetMb,
iterations: options.iterations,
walSizeLargeMiB: walMb,
walCheckpoint: checkpoint,
phaseResults: [
{ phase: 'large-wal', walMiB: walMb, ...before },
{ phase: 'after-checkpoint', walMiB: 0, ...after },
],
delta: {
getAllSlowerPct: pct(before.getAllMedianMs, after.getAllMedianMs),
getByIdSlowerPct: pct(before.getByIdMedianMs, after.getByIdMedianMs),
searchSlowerPct: pct(before.searchMedianMs, after.searchMedianMs),
},
dbPath,
};
} finally {
if (!options.keep) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}

async function main(): Promise<void> {
const options = parseArgs(process.argv);
const summary = runBenchmark(process.argv);
if (options.json) {
console.log(JSON.stringify({ ...summary, timestamp: new Date().toISOString() }, null, 2));
} else {
console.log('WAL read-latency benchmark');
console.log(`- items: ${summary.items}`);
console.log(`- walTargetMiB: ${summary.walTargetMiB}`);
console.log(`- iterations: ${summary.iterations}`);
console.log(`- large WAL size: ${summary.walSizeLargeMiB.toFixed(2)} MiB`);
console.log(`- wal_checkpoint(TRUNCATE): ${JSON.stringify(summary.walCheckpoint)}`);
console.log('');
console.table(summary.phaseResults.map((r) => ({
phase: r.phase,
'WAL MiB': r.walMiB,
'getAllWorkItems ms': r.getAllMedianMs,
'getWorkItem(id) ms': r.getByIdMedianMs,
'searchFts ms': r.searchMedianMs,
})));
console.log('');
console.log('Large-WAL vs checkpointed slowdown:');
console.log(` getAllWorkItems: ${summary.delta.getAllSlowerPct.toFixed(1)}%`);
console.log(` getWorkItem(id): ${summary.delta.getByIdSlowerPct.toFixed(1)}%`);
console.log(` searchFts: ${summary.delta.searchSlowerPct.toFixed(1)}%`);
console.log(` dbPath: ${summary.dbPath}`);
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((err) => {
console.error('benchmark failed:', err);
process.exitCode = 1;
});
}
Loading
Loading