trekoon 0.4.5 → 0.4.6

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.
@@ -398,6 +398,57 @@ export function openTrekoonDatabase(
398
398
  db.exec("PRAGMA journal_mode = WAL;");
399
399
  db.exec("PRAGMA foreign_keys = ON;");
400
400
 
401
+ // Open-time read+write throughput tuning. WAL is required (set above);
402
+ // the rest are layered on top.
403
+ //
404
+ // synchronous: WAL+NORMAL is the documented standard pairing
405
+ // (https://www.sqlite.org/wal.html#performance_considerations) — durability
406
+ // semantics: on a hard kernel/OS crash the last unfsynced transactions can
407
+ // be lost, but the DB file itself never corrupts. Operators who want the
408
+ // pre-tuning behaviour (synchronous=FULL) can set
409
+ // TREKOON_SQLITE_DURABILITY=full at open time.
410
+ const durabilityMode: string = (process.env.TREKOON_SQLITE_DURABILITY ?? "").toLowerCase();
411
+ if (durabilityMode === "full") {
412
+ db.exec("PRAGMA synchronous = FULL;");
413
+ } else {
414
+ db.exec("PRAGMA synchronous = NORMAL;");
415
+ }
416
+
417
+ // temp_store=MEMORY keeps temp B-tree sorts and intermediate result sets
418
+ // out of the temp file on disk — most relevant for the ORDER BY paths
419
+ // that pre-0013 schemas relied on temp-b-tree sorts for.
420
+ db.exec("PRAGMA temp_store = MEMORY;");
421
+
422
+ // mmap_size: opportunistic memory-mapped reads for the first 256 MiB of
423
+ // the DB file. Reduces read syscall overhead for hot pages. bun:sqlite
424
+ // forwards the PRAGMA to libsqlite3; the connection will silently keep
425
+ // the previous value if the platform does not support mmap.
426
+ db.exec("PRAGMA mmap_size = 268435456;");
427
+
428
+ // cache_size negative value -> KiB, positive value -> pages.
429
+ // Default: 64 MiB. Override with TREKOON_SQLITE_CACHE_MIB (integer MiB).
430
+ // Negative values are rejected. With 16-handle daemon mode the per-process
431
+ // page cache approaches CACHED_DATABASES_CAPACITY × cache_size, so
432
+ // operators should lower TREKOON_SQLITE_CACHE_MIB (e.g. to 16) when memory
433
+ // is constrained.
434
+ const cacheMibRaw: string = (process.env.TREKOON_SQLITE_CACHE_MIB ?? "").trim();
435
+ const cacheMib: number = cacheMibRaw.length > 0 ? Number(cacheMibRaw) : 64;
436
+ if (!Number.isInteger(cacheMib) || cacheMib < 0) {
437
+ throw new DomainError({
438
+ code: "invalid_input",
439
+ message:
440
+ `TREKOON_SQLITE_CACHE_MIB must be a non-negative integer (got ${JSON.stringify(cacheMibRaw)}).`,
441
+ details: { envVar: "TREKOON_SQLITE_CACHE_MIB", provided: cacheMibRaw },
442
+ });
443
+ }
444
+ db.exec(`PRAGMA cache_size = ${-(cacheMib * 1024)};`);
445
+
446
+ // Trigger a checkpoint roughly every 1000 frames so the WAL file does
447
+ // not grow unbounded under sustained writes. Default is 1000 already,
448
+ // but we pin it explicitly so the value cannot drift if libsqlite3
449
+ // changes its default in a future bump.
450
+ db.exec("PRAGMA wal_autocheckpoint = 1000;");
451
+
401
452
  if (options.autoMigrate ?? true) {
402
453
  migrateDatabase(db);
403
454
  }
@@ -429,8 +480,22 @@ export function openTrekoonDatabase(
429
480
  paths,
430
481
  diagnostics,
431
482
  close(): void {
432
- db.exec("PRAGMA wal_checkpoint(PASSIVE);");
433
- db.close(false);
483
+ // Best-effort checkpoint: matches closeCachedHandle's posture. WAL
484
+ // checkpointing is maintenance, not durability — skipping it cannot
485
+ // corrupt the DB. Suppressing errors here lets read-only contexts
486
+ // (read-only filesystem, immutable DB file, sandboxed agents) close
487
+ // cleanly instead of throwing SQLITE_READONLY on the very last
488
+ // syscall before db.close().
489
+ try {
490
+ db.exec("PRAGMA wal_checkpoint(PASSIVE);");
491
+ } catch {
492
+ /* best effort — checkpoint is maintenance, not durability */
493
+ }
494
+ try {
495
+ db.close(false);
496
+ } catch {
497
+ /* best effort — handle may already be closing */
498
+ }
434
499
  },
435
500
  };
436
501
  }
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
5
 
6
6
  import { DomainError } from "../domain/types";
7
- import { BASE_SCHEMA_STATEMENTS, SCHEMA_VERSION } from "./schema";
7
+ import { BASE_SCHEMA_REVISION, BASE_SCHEMA_STATEMENTS } from "./schema";
8
8
 
9
9
  const BACKUP_HINT = "Run 'trekoon migrate backup' to snapshot .trekoon/trekoon.db before any manual recovery.";
10
10
 
@@ -23,7 +23,7 @@ function migrationDownUnsupported(migrationName: string, version: number): Domai
23
23
  }
24
24
 
25
25
  const BASE_MIGRATION_VERSION = 1;
26
- const BASE_MIGRATION_NAME = `0001_base_schema_v${SCHEMA_VERSION}`;
26
+ const BASE_MIGRATION_NAME = `0001_base_schema_v${BASE_SCHEMA_REVISION}`;
27
27
  const LEGACY_BASE_MIGRATION_NAME_PATTERNS: readonly string[] = [
28
28
  "0001_base_schema_v*",
29
29
  ];
@@ -138,6 +138,116 @@ const SYNC_CONFLICTS_SCOPE_DOWN_STATEMENTS: readonly string[] = [
138
138
  "DROP INDEX IF EXISTS idx_sync_conflicts_scope_resolution;",
139
139
  ];
140
140
 
141
+ const TASK_ORDERED_SCAN_INDEX_UP_STATEMENTS: readonly string[] = [
142
+ "CREATE INDEX IF NOT EXISTS idx_tasks_epic_created ON tasks(epic_id, created_at, id);",
143
+ "CREATE INDEX IF NOT EXISTS idx_subtasks_task_created ON subtasks(task_id, created_at, id);",
144
+ ];
145
+
146
+ const TASK_ORDERED_SCAN_INDEX_DOWN_STATEMENTS: readonly string[] = [
147
+ "DROP INDEX IF EXISTS idx_subtasks_task_created;",
148
+ "DROP INDEX IF EXISTS idx_tasks_epic_created;",
149
+ ];
150
+
151
+ const DEPENDENCY_KIND_INDEX_DOWN_STATEMENTS: readonly string[] = [
152
+ "DROP INDEX IF EXISTS uniq_dependencies_edge;",
153
+ "DROP INDEX IF EXISTS idx_dependencies_target;",
154
+ "DROP INDEX IF EXISTS idx_dependencies_source;",
155
+ // v12 dropped the v2 single-column source/target indexes to make room for
156
+ // the composite versions of the same names. Restore the v2 indexes on
157
+ // rollback so v11 lookups continue to benefit from them.
158
+ "CREATE INDEX IF NOT EXISTS idx_dependencies_source ON dependencies(source_id);",
159
+ "CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies(depends_on_id);",
160
+ ];
161
+
162
+ /**
163
+ * Migration 0012: dedupe dependency rows that share the full polymorphic
164
+ * edge (source_id, source_kind, depends_on_id, depends_on_kind), then add
165
+ * a source-side composite index, a target-side composite index, and a
166
+ * UNIQUE index on the full 4-column edge. This closes the polymorphic-FK
167
+ * gap left by 0005 (which made (source_id, depends_on_id) unique but did
168
+ * not include kind columns in the UNIQUE constraint).
169
+ *
170
+ * Step 1 is idempotent: the DELETE is a no-op when no duplicates exist.
171
+ * The dedupe keeps the row with the lowest created_at per logical edge.
172
+ * The dropped duplicates are not recoverable — rollback only drops the
173
+ * indexes; the migration is irreversibly destructive of duplicate rows,
174
+ * which is why down() throws migration_down_unsupported.
175
+ */
176
+ function migrateDependencyKindIndexes(db: Database): void {
177
+ // Defensive: skip the migration entirely if the dependencies table is
178
+ // missing. This guards partial-schema test fixtures (and any future
179
+ // legacy DBs that arrive without the v1 base schema) the same way
180
+ // migrateSyncConflictsScope and migrateBoardIdempotencyState do.
181
+ if (!tableExists(db, "dependencies")) {
182
+ return;
183
+ }
184
+
185
+ // Step 1: dedupe rows that share the full edge, keeping the lowest
186
+ // created_at survivor (tiebreak on id for determinism). Performed under
187
+ // the same exclusive transaction the migration runner holds.
188
+ //
189
+ // EXISTS guard: skip the expensive window-function DELETE entirely when
190
+ // the table has no duplicate edges. This avoids a full-table scan on
191
+ // clean databases and prevents spurious console.warn output.
192
+ const hasDuplicates = db
193
+ .query(
194
+ `
195
+ SELECT 1 FROM dependencies
196
+ GROUP BY source_id, source_kind, depends_on_id, depends_on_kind
197
+ HAVING count(*) > 1
198
+ LIMIT 1;
199
+ `,
200
+ )
201
+ .get() as Record<string, unknown> | null;
202
+
203
+ if (hasDuplicates !== null) {
204
+ const dedupeResult = db
205
+ .query(
206
+ `
207
+ DELETE FROM dependencies
208
+ WHERE id NOT IN (
209
+ SELECT id FROM (
210
+ SELECT id,
211
+ ROW_NUMBER() OVER (
212
+ PARTITION BY source_id, source_kind, depends_on_id, depends_on_kind
213
+ ORDER BY created_at ASC, id ASC
214
+ ) AS rn
215
+ FROM dependencies
216
+ )
217
+ WHERE rn = 1
218
+ );
219
+ `,
220
+ )
221
+ .run();
222
+
223
+ const dedupedCount: number = Number(dedupeResult.changes ?? 0);
224
+ if (dedupedCount > 0) {
225
+ console.warn(
226
+ `[trekoon] migration 0012_dependency_kind_indexes: removed ${dedupedCount} duplicate dependency edge(s) ` +
227
+ "before adding uniq_dependencies_edge UNIQUE index (irreversible).",
228
+ );
229
+ }
230
+ }
231
+
232
+ // Step 2: indexes that accelerate the polymorphic listDependencies /
233
+ // listReverseDependencies / addDependency lookup paths.
234
+ //
235
+ // v2 already created single-column idx_dependencies_source(source_id) and
236
+ // idx_dependencies_depends_on(depends_on_id). Replace both with composite
237
+ // (id, kind) indexes — the source-side keeps the v2 name so the schema
238
+ // surface stays minimal, the target-side gets a new idx_dependencies_target
239
+ // name. We drop the v2 indexes first because CREATE INDEX IF NOT EXISTS
240
+ // would otherwise be a no-op against the existing single-column index of
241
+ // the same name.
242
+ db.exec("DROP INDEX IF EXISTS idx_dependencies_source;");
243
+ db.exec("DROP INDEX IF EXISTS idx_dependencies_depends_on;");
244
+ db.exec("CREATE INDEX IF NOT EXISTS idx_dependencies_source ON dependencies(source_id, source_kind);");
245
+ db.exec("CREATE INDEX IF NOT EXISTS idx_dependencies_target ON dependencies(depends_on_id, depends_on_kind);");
246
+ db.exec(
247
+ "CREATE UNIQUE INDEX IF NOT EXISTS uniq_dependencies_edge ON dependencies(source_id, source_kind, depends_on_id, depends_on_kind);",
248
+ );
249
+ }
250
+
141
251
  function migrateSyncConflictsScope(db: Database): void {
142
252
  if (!tableExists(db, "sync_conflicts")) {
143
253
  return;
@@ -454,6 +564,43 @@ const MIGRATIONS: readonly Migration[] = [
454
564
  }
455
565
  },
456
566
  },
567
+ {
568
+ version: 12,
569
+ name: "0012_dependency_kind_indexes",
570
+ up(db: Database): void {
571
+ migrateDependencyKindIndexes(db);
572
+ },
573
+ down(db: Database): void {
574
+ // up() deduplicates rows sharing the full polymorphic edge
575
+ // (source_id, source_kind, depends_on_id, depends_on_kind); those
576
+ // row deletions are unrecoverable. down() therefore drops only the
577
+ // new indexes — schema-level recovery — and leaves data restoration
578
+ // to operators (see the migrate help notes for the backup workflow).
579
+ for (const statement of DEPENDENCY_KIND_INDEX_DOWN_STATEMENTS) {
580
+ db.exec(statement);
581
+ }
582
+ },
583
+ },
584
+ {
585
+ version: 13,
586
+ name: "0013_task_ordered_scan_indexes",
587
+ up(db: Database): void {
588
+ // Guard against partial-schema fixtures the same way 0011/0012 do.
589
+ // If tasks or subtasks are missing the base v1 schema never ran;
590
+ // there is nothing to index against.
591
+ if (!tableExists(db, "tasks") || !tableExists(db, "subtasks")) {
592
+ return;
593
+ }
594
+ for (const statement of TASK_ORDERED_SCAN_INDEX_UP_STATEMENTS) {
595
+ db.exec(statement);
596
+ }
597
+ },
598
+ down(db: Database): void {
599
+ for (const statement of TASK_ORDERED_SCAN_INDEX_DOWN_STATEMENTS) {
600
+ db.exec(statement);
601
+ }
602
+ },
603
+ },
457
604
  ];
458
605
 
459
606
  function migrationTableExists(db: Database): boolean {
@@ -1,4 +1,4 @@
1
- export const SCHEMA_VERSION = 3;
1
+ export const BASE_SCHEMA_REVISION = 5;
2
2
 
3
3
  export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
4
4
  `PRAGMA foreign_keys = ON;`,
@@ -142,4 +142,9 @@ export const BASE_SCHEMA_STATEMENTS: readonly string[] = [
142
142
  `CREATE INDEX IF NOT EXISTS idx_sync_conflicts_scope_resolution ON sync_conflicts(worktree_path, current_branch, resolution);`,
143
143
  `CREATE INDEX IF NOT EXISTS idx_board_idempotency_created_at ON board_idempotency_keys(created_at);`,
144
144
  `CREATE INDEX IF NOT EXISTS idx_board_idempotency_state_created_at ON board_idempotency_keys(state, created_at);`,
145
+ `CREATE INDEX IF NOT EXISTS idx_dependencies_source ON dependencies(source_id, source_kind);`,
146
+ `CREATE INDEX IF NOT EXISTS idx_dependencies_target ON dependencies(depends_on_id, depends_on_kind);`,
147
+ `CREATE UNIQUE INDEX IF NOT EXISTS uniq_dependencies_edge ON dependencies(source_id, source_kind, depends_on_id, depends_on_kind);`,
148
+ `CREATE INDEX IF NOT EXISTS idx_tasks_epic_created ON tasks(epic_id, created_at, id);`,
149
+ `CREATE INDEX IF NOT EXISTS idx_subtasks_task_created ON subtasks(task_id, created_at, id);`,
145
150
  ];
@@ -14,6 +14,16 @@ interface EventRecordInput {
14
14
  interface EventWriteContext {
15
15
  readonly git: ResolvedGitContext;
16
16
  nextTimestamp: number;
17
+ /**
18
+ * Transaction-scoped guard: `persistGitContext` upserts the same
19
+ * (worktree_path, branch_name, head_sha) row on every call, so doing it
20
+ * once per appended event row is wasted IO under bulk creates. The first
21
+ * `appendEventWithGitContext` inside the transaction flips this to `true`;
22
+ * subsequent appends within the same write lock skip the upsert. Per-event
23
+ * rows still carry `git_branch` / `git_head` from `context.git`, so the
24
+ * events-table contract is unchanged.
25
+ */
26
+ gitPersisted: boolean;
17
27
  }
18
28
 
19
29
  const transactionEventContexts: WeakMap<Database, EventWriteContext> = new WeakMap();
@@ -79,7 +89,7 @@ export function withTransactionEventContext<T>(db: Database, git: ResolvedGitCon
79
89
  // subprocess invocations happen here.
80
90
  const nextTimestamp: number = nextEventTimestamp(db);
81
91
  const resolvedGit: ResolvedGitContext = { ...git, persistedAt: nextTimestamp };
82
- const context: EventWriteContext = { git: resolvedGit, nextTimestamp };
92
+ const context: EventWriteContext = { git: resolvedGit, nextTimestamp, gitPersisted: false };
83
93
 
84
94
  transactionEventContexts.set(db, context);
85
95
 
@@ -101,7 +111,19 @@ export function appendEventWithGitContext(
101
111
  const git: ResolvedGitContext = context?.git ?? resolveGitContext(cwd, now);
102
112
  const eventId: string = randomUUID();
103
113
 
104
- persistGitContext(db, git, now);
114
+ // Bulk mutations append many events under one BEGIN IMMEDIATE write lock.
115
+ // persistGitContext upserts the same (worktree_path, branch_name, head_sha)
116
+ // row every call, so we only need it once per transaction. Inside a
117
+ // transaction context the first call flips `gitPersisted` to `true` and
118
+ // subsequent appends skip the redundant upsert. Single-event paths (no
119
+ // active transaction context) still upsert on every call — preserving
120
+ // existing behaviour for direct callers like sync-helpers.
121
+ if (context === undefined) {
122
+ persistGitContext(db, git, now);
123
+ } else if (!context.gitPersisted) {
124
+ persistGitContext(db, git, now);
125
+ context.gitPersisted = true;
126
+ }
105
127
 
106
128
  if (context) {
107
129
  context.nextTimestamp += 1;
@@ -1,170 +0,0 @@
1
- # Execution With Agent Teams Reference
2
-
3
- You are a team lead orchestrator. Use this file only for Claude Code Agent
4
- Teams when the user explicitly asks for team execution and
5
- `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=true`. This is a runtime-specific path,
6
- not the default subagent path for Codex, OpenCode, Pi, or other harnesses.
7
-
8
- Team execution is complete only when the epic is marked `done`, all remaining
9
- work is blocked with recorded reasons, or real user input is required.
10
-
11
- Clarify meaningful ambiguity before starting.
12
-
13
- ## Start
14
-
15
- Build the graph with the standard execution reference: `task ready`,
16
- `dep reverse`, lane grouping, and first-wave validation. Then mark the epic in
17
- progress:
18
-
19
- ```bash
20
- trekoon --toon epic update <epic-id> --status in_progress
21
- ```
22
-
23
- ## Create Team And Tasks
24
-
25
- 1. Create the team:
26
-
27
- ```text
28
- TeamCreate:
29
- team_name: "<epic-slug>"
30
- description: "Executing epic <epic-id>: <title>"
31
- ```
32
-
33
- 2. Create one shared team task per lane:
34
-
35
- ```text
36
- TaskCreate:
37
- subject: "<lane>: <task-ids/titles>"
38
- description: |
39
- Execute these Trekoon tasks IN ORDER unless task descriptions allow
40
- parallel subtasks:
41
- - Task <id>: <title>
42
-
43
- Before each task:
44
- - trekoon --toon task claim <id> --owner <lane-name>
45
- - trekoon --toon task update <id> --append "Starting implementation"
46
-
47
- While working:
48
- - Complete required subtasks.
49
- - Append progress notes; do not rewrite task descriptions.
50
- - Use task done for task completion.
51
- - For subtasks, claim or move through in_progress before done.
52
- - Keep parallel Trekoon Bash calls read-only; serialize status-changing
53
- commands unless using atomic claim.
54
- - Use --compact for noisy Trekoon reads.
55
-
56
- On completion:
57
- - Append verification evidence.
58
- - trekoon --toon task done <id>
59
- - Report unblocked tasks, open subtask warnings, and next candidate via
60
- SendMessage.
61
- - Report review result or review gap for non-trivial code changes.
62
-
63
- If blocked:
64
- - Append blocker reason, dependency id, and exact failing command/output.
65
- - trekoon --toon task update <id> --append "Blocked by <reason>" --status blocked
66
- - Notify team lead via SendMessage.
67
-
68
- Do not create branches, commits, pushes, or PRs unless the user explicitly
69
- asked and harness policy allows it.
70
- ```
71
-
72
- Use `blockedBy` via TaskUpdate for team tasks that must run sequentially.
73
-
74
- 3. Spawn one teammate per parallel lane:
75
-
76
- ```text
77
- Agent:
78
- name: "developer-1"
79
- team_name: "<epic-slug>"
80
- subagent_type: "general-purpose"
81
- description: "<lane>: <task titles>"
82
- prompt: |
83
- You are a developer on team "<epic-slug>".
84
- Work through your TaskList assignment.
85
- Claim each Trekoon task before editing:
86
- trekoon --toon task claim <trekoon-task-id> --owner <your-name>
87
-
88
- Use task done for task completion. For subtasks, claim or move through
89
- in_progress before done. Do not batch multiple Trekoon status-changing Bash
90
- calls in one parallel tool turn. Read and report unblocked tasks, warnings,
91
- and next candidate via SendMessage.
92
-
93
- Communicate blockers and coordination needs via SendMessage.
94
- ```
95
-
96
- Use 3-5 teammates for most epics. Do not over-parallelize. Use
97
- `general-purpose` for implementation and `Explore`/`Plan` only for read-only
98
- research or planning.
99
-
100
- ## Coordinate
101
-
102
- Your job as team lead:
103
-
104
- 1. Monitor SendMessage updates.
105
- 2. When a teammate reports `unblocked` tasks from `task done`, create new team
106
- tasks and assign idle teammates.
107
- 3. Help resolve or reassign blockers.
108
- 4. Keep Trekoon owners current:
109
- ```bash
110
- trekoon --toon task update <task-id> --owner <teammate-name>
111
- ```
112
- 5. Use SendMessage to direct teammates.
113
- 6. Check progress:
114
- ```bash
115
- trekoon --toon epic progress <epic-id>
116
- ```
117
- 7. When all teammates are blocked, run:
118
- ```bash
119
- trekoon --toon suggest --epic <epic-id>
120
- ```
121
-
122
- ## Recovery
123
-
124
- Use the standard execution recovery rules. Teammates should try to fix failures
125
- with their local context. If they cannot, they must report exact command/output
126
- via SendMessage so you can give fix instructions or reassign.
127
-
128
- For `status_transition_invalid`, inspect current status with:
129
-
130
- ```bash
131
- trekoon --toon --compact task show <id>
132
- ```
133
-
134
- If the error came from a cancelled parallel Bash batch, first re-read the
135
- affected task or subtask, then retry only the valid next transition. Do not
136
- replay the whole batch.
137
-
138
- For `dependency_blocked`, inspect the dependency, append a blocker note, then
139
- continue with a ready candidate from:
140
-
141
- ```bash
142
- trekoon --toon task ready --epic <epic-id>
143
- ```
144
-
145
- ## Verify And Close
146
-
147
- Use the standard execution verification rules: review, automated tests, manual
148
- checks, DX quality, and Trekoon evidence notes.
149
-
150
- After all work is verified:
151
-
152
- ```bash
153
- trekoon --toon epic progress <epic-id>
154
- trekoon --toon suggest --epic <epic-id>
155
- trekoon --toon epic update <epic-id> --status done
156
- ```
157
-
158
- Then send `shutdown_request` to each teammate, delete the team with TeamDelete,
159
- and return completed tasks, files changed, verification, review, remaining
160
- blockers, and dependency state.
161
-
162
- ## Team Tools
163
-
164
- | Purpose | Tool |
165
- |---|---|
166
- | Create team | `TeamCreate` |
167
- | Manage shared tasks | `TaskCreate` / `TaskList` / `TaskUpdate` / `TaskGet` |
168
- | Spawn teammates | `Agent` with `team_name` |
169
- | Communicate | `SendMessage` |
170
- | Clean up | `TeamDelete` |