Skip to content

feat(db-sync): reconcile Genie databases safely - #2737

Closed
lirazsiri wants to merge 21 commits into
automagik-dev:mainfrom
lirazsiri:feature/genie-database-reconciliation
Closed

lirazsiri wants to merge 21 commits into
automagik-dev:mainfrom
lirazsiri:feature/genie-database-reconciliation

Conversation

@lirazsiri

@lirazsiri lirazsiri commented Jul 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add standalone genie db sync planning and apply flows for bidirectional and directional reconciliation
  • reconcile current Genie task, board, wish-group, roster, metadata, dependency, stage, and event state with bounded conflict reports
  • add transactional advisory/SQLite locking, durable snapshots, rollback, recovery, retention, and fail-closed filesystem publication
  • bootstrap an absent database from the complete logical image of its existing peer, including committed WAL content
  • propagate explicit hire-roster deletion tombstones without treating ordinary absence as deletion
  • document the CLI modes, exit codes, safety model, and recovery workflow

Why

Genie repositories can acquire independent host and guest genie.db state. Previously there was no supported way to inspect, reconcile, recover, or bootstrap those databases without replacing files manually.

This adds an explicit zero-daemon synchronization command. Bidirectional mode merges non-conflicting state and refuses mutable-row ambiguity. Directional mode makes the source authoritative for shared mutable rows while preserving destination-only rows.

Safety and behavior

  • validates the exact-current closed Genie schema before granting reconciliation authority
  • uses canonical advisory locks and SQLite transactions under one bounded wait deadline
  • revalidates physical identities and logical preimages under lock
  • snapshots target preimages before mutation and supports explicit rollback
  • detects partial commits and performs bounded recovery
  • publishes missing-side bootstrap images without clobbering an appearing target
  • supports user-owned group-writable repository directories while rejecting world-writable, non-owner, symlinked, or non-directory targets
  • preserves committed WAL-backed state
  • reports bounded identities, digests, counts, and typed failures without leaking row content or hostile paths

Validation

  • bun run check
    • 3,107 tests passed
    • 0 failures
    • 11,979 assertions
    • TypeScript, Biome, dead-code, skill/wish/council, hook, and plugin gates passed
  • rebased onto current automagik-dev/genie:main
  • real helloworld sandbox smoke:
    • bootstrapped a genuinely absent guest database under a mode 0775 .genie directory
    • ran guest task claim, report, and completion lifecycle
    • directionally synchronized guest state back to the host
    • confirmed equal logical digests and a repeat bidirectional no-op

Notes

The two existing doctor.ts cognitive-complexity warnings remain within the repository's explicit complexity budget and are unrelated to this change.

Summary by CodeRabbit

  • New Features
    • Added genie db sync for bidirectional or directional database reconciliation.
    • Added dry runs, conflict detection, JSON reports, configurable timeouts, and bounded failure reporting.
    • Added durable snapshots, recovery, rollback, retention controls, and missing-database bootstrap support.
    • Unhiring agents now preserves synchronization state for reliable reconciliation.
  • Documentation
    • Updated command documentation and database reconciliation guidance.
  • Bug Fixes
    • Improved safety for concurrent updates, interrupted operations, invalid inputs, database locking, and filesystem permissions.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds v5 SQLite reconciliation with schema validation, locking, tombstone propagation, durable snapshots, recovery, rollback, and the genie db sync command. Tests cover planning, transactional failure handling, filesystem safety, retention, recovery, CLI behavior, and deterministic fixtures.

Changes

V5 database synchronization

Layer / File(s) Summary
Reconciliation contracts and planning
src/lib/v5/db-reconciliation.ts, src/lib/v5/reconciliation-tombstone.ts, src/lib/v5/task-state.ts, src/lib/v5/*test.ts
Defines reconciliation contracts, strict schema and logical-image validation, deterministic plans, tombstone encoding, and hire-roster tombstone behavior.
Locked transactional apply
src/lib/v5/db-reconciliation.ts, src/lib/v5/db-reconciliation.test.ts
Adds canonical advisory and SQLite locking, guarded mutations, postimage verification, commit classification, rollback handling, and locked-operation support.
Snapshot publication and recovery
src/lib/v5/db-sync-snapshots.ts, src/lib/v5/db-sync-snapshots.test.ts
Adds descriptor-relative snapshot generations, missing-database bootstrap, recovery, rollback, retention, manifest validation, and filesystem safety checks.
CLI command and integration
src/term-commands/v5-db-sync.ts, src/term-commands/v5-db-sync.test.ts, src/genie.ts, src/lib/interactivity.ts, scripts/release-docs.test.ts, src/term-commands/ui-bridge.ts
Registers genie db sync, validates options, formats reports, maps exit codes, bypasses workspace checks for db, and updates command documentation contracts.
Deterministic test environments
src/genie-commands/__tests__/*, src/lib/agent-sync.test.ts, tests/support/*
Uses explicit permissions, deterministic update selection, explicit transaction fixture trees, normalized historical skill copies, and isolated harness roots.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI as genie db sync
  participant Snapshots as db-sync-snapshots
  participant Reconciliation as db-reconciliation
  participant SQLite as SQLite databases
  CLI->>Snapshots: plan or apply synchronization
  Snapshots->>Reconciliation: reconcile database images
  Reconciliation->>SQLite: validate, lock, mutate, and verify
  SQLite-->>Reconciliation: postimage and commit status
  Reconciliation-->>Snapshots: reconciliation report
  Snapshots-->>CLI: sync, recovery, rollback, or cleanup result
Loading

Possibly related PRs

  • automagik-dev/genie#2510: Registers another top-level command and modifies src/genie.ts, src/lib/interactivity.ts, and src/lib/v5/task-state.ts.

Suggested reviewers: namastex888

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely identifies the main change: safe reconciliation of Genie databases through database synchronization.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@namastex888
namastex888 marked this pull request as ready for review July 30, 2026 19:20
namastex888
namastex888 previously approved these changes Jul 30, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac3a1d0c32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if (errno[0] !== 0) throw nativeError('readdir', errno[0]);
return entries;
}
const name = new CString(entry, nameOffset).toString();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Read directory names from the dirent name field

On the supported Linux/Darwin path, the second CString argument is a byte-length bound rather than a pointer offset, so this decodes the binary dirent header instead of d_name. Consequently list() returns malformed names, matchingGenerationNames() never finds published generations, and recovery, rollback, retention, and staging cleanup all fail to discover their files; after a partial commit, a later sync can proceed without restoring the retained preimages.

Useful? React with 👍 / 👎.

Comment thread src/lib/v5/db-reconciliation.ts Outdated
Comment on lines +2188 to +2190
if (conflicts.length === 0) {
applyTombstonesToState(left);
applyTombstonesToState(right);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow a newer hire to supersede replicated tombstones

After an unhire has synchronized, both replicas retain the tombstone; rehiring on one replica clears only its local marker. The next bidirectional sync unions the remote marker back, and directional sync preserves it as a destination-only meta row, so these calls delete the newly hired row from both sides. A tombstone therefore needs ordering or explicit resurrection semantics rather than being applied unconditionally, otherwise an agent can never be rehired after its deletion has propagated.

Useful? React with 👍 / 👎.

Comment thread src/lib/v5/db-reconciliation.ts Outdated
Comment on lines +824 to +825
if (left.hasSidecars || right.hasSidecars) {
throw error('invalid-data', 'Hardlink aliases with path-specific SQLite sidecars are ambiguous.');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish inert SQLite sidecars from committed WAL state

For hardlink aliases of an ordinary Genie database, Bun can leave an empty -wal and an inert -shm after all handles close, because openDb enables WAL mode. Treating mere pathname existence as ambiguity therefore rejects otherwise safe same-database no-ops; the newly added hardlink-alias cases fail before planning on Bun 1.2.14. The ambiguity check should inspect whether a path-specific sidecar contains relevant live/committed state rather than rejecting every leftover sidecar.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/release-docs.test.ts`:
- Around line 752-760: Update the test describing database reconciliation docs
to normalize README whitespace before asserting text, so expectations such as
the explicit path and combined lock wait claims are unaffected by hard-wrap
reflow. Apply the same collapsed-whitespace comparison to all related assertions
in the test while preserving the existing positive and negative claims.

In `@src/genie.ts`:
- Around line 203-207: Remove the process.argv-based requestedRootCommand check
and rely on installWorkspaceCheck’s existing per-command gating. Update
commandRequiresWorkspace so the db command is treated as standalone alongside
task and board, while preserving workspace validation for other commands.

In `@src/lib/v5/db-reconciliation.test.ts`:
- Around line 64-101: Update spawnFlockHolder to choose the flock library
candidates by platform, matching resolveAdvisoryFlock: use
linuxLibcCandidates(process.arch) on Linux and /usr/lib/libSystem.B.dylib on
darwin. Preserve the existing child setup and readiness polling, and ensure the
lock tests can complete successfully on macOS.

In `@src/lib/v5/db-sync-snapshots.test.ts`:
- Around line 2010-2041: Update the zero-retention test’s private-root discovery
in the test beginning “zero retention uses private 0700 state...” to use the
existing privateRoot.temporaryDirectory fixture seam instead of scanning the
shared OS tmpdir via readdirSync(tmpdir()). Also update the related
privateSnapshotDirectories and inline scans in the nearby tests to inspect only
that fixture directory, preserving the existing assertions and cleanup behavior.

In `@src/lib/v5/db-sync-snapshots.ts`:
- Around line 557-568: Update the renameAt wrapper to match openAt, mkdirAt,
linkAt, and unlinkAt: capture the native rename failure errno and throw
nativeError with the rename operation and errno value instead of a bare Error,
preserving the existing successful path.

In `@src/term-commands/v5-db-sync.test.ts`:
- Around line 75-89: Update the cli spawn helper to isolate each spawned CLI's
global state by setting GENIE_HOME in its env to a temporary directory, while
preserving the existing inherited environment and test-specific variables. Apply
the same isolation to the other spawn helper referenced by the comment so task
and board commands never use the real ~/.genie state.
- Around line 305-339: Replace the positional `index >= 8` assertion in the
`cases` matrix loop with per-case metadata containing the expected stderr
fragment. Update duplicate-option cases to specify “may be specified only once,”
leave other cases without an expectation, and assert the fragment from each test
case’s metadata rather than relying on insertion order.

In `@src/term-commands/v5-db-sync.ts`:
- Around line 118-128: Type DatabaseSyncCliReport.status using the upstream
report-status union rather than string, and update exitCode to exhaustively
handle that union while mapping unknown runtime values to operationalFailure
instead of success. Apply the same status typing and fallback behavior in the
related code around exitCode.
- Around line 462-465: Update cleanupFailureCount to include
report.apply.recovery.cleanupFailures alongside report.apply.cleanupFailures
when apply is present, so recovery-phase cleanup failures contribute to the exit
code and human summary. Preserve the existing rollback cleanup count behavior
when apply is null.
- Around line 147-159: The repeated-option validation must use Commander’s
parsed raw arguments rather than reading process.argv indirectly. Update the
handleSync/action flow and rejectRepeatedOptions call to pass the raw-args slice
from program.parseAsync(args), preserving the existing duplicate detection
behavior.

In `@tests/support/codex-dogfood-harness.ts`:
- Line 271: Update the temporary-directory setup around mkdtempSync and
mkdirSync: do not use mkdirSync to change the existing root permissions. Remove
the redundant mkdirSync call to preserve private temporary-directory
permissions, or replace it with chmodSync only if mode 755 is explicitly
required.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 06e03218-faed-46a6-a959-6bb0b19e9759

📥 Commits

Reviewing files that changed from the base of the PR and between a05a1a0 and ac3a1d0.

⛔ Files ignored due to path filters (2)
  • CLAUDE.md is excluded by !*.md
  • README.md is excluded by !*.md
📒 Files selected for processing (18)
  • scripts/release-docs.test.ts
  • src/genie-commands/__tests__/update-command-publication.test.ts
  • src/genie-commands/__tests__/update.test.ts
  • src/genie-commands/local-delivery-repair.test.ts
  • src/genie.ts
  • src/lib/agent-sync.test.ts
  • src/lib/v5/db-reconciliation.test.ts
  • src/lib/v5/db-reconciliation.ts
  • src/lib/v5/db-sync-snapshots.test.ts
  • src/lib/v5/db-sync-snapshots.ts
  • src/lib/v5/reconciliation-tombstone.ts
  • src/lib/v5/task-state.test.ts
  • src/lib/v5/task-state.ts
  • src/term-commands/ui-bridge.ts
  • src/term-commands/v5-db-sync.test.ts
  • src/term-commands/v5-db-sync.ts
  • tests/support/codex-dogfood-harness.ts
  • tests/support/update-current-boundary-runner.ts

Comment thread scripts/release-docs.test.ts
Comment thread src/genie.ts Outdated
Comment thread src/lib/v5/db-reconciliation.test.ts
Comment thread src/lib/v5/db-sync-snapshots.test.ts
Comment thread src/lib/v5/db-sync-snapshots.ts
Comment thread src/term-commands/v5-db-sync.test.ts Outdated
Comment thread src/term-commands/v5-db-sync.ts
Comment thread src/term-commands/v5-db-sync.ts
Comment thread src/term-commands/v5-db-sync.ts
Comment thread tests/support/codex-dogfood-harness.ts Outdated
const ownsRoot = dependencies.root === undefined;
const root = dependencies.root ?? mkdtempSync(join(tmpdir(), 'genie-dogfood-entry-'));
mkdirSync(root, { recursive: true });
mkdirSync(root, { recursive: true, mode: 0o755 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node <<'NODE'
const { mkdtempSync, mkdirSync, statSync, rmSync } = require('node:fs');
const { join } = require('node:path');
const { tmpdir } = require('node:os');

const root = mkdtempSync(join(tmpdir(), 'genie-mode-check-'));
const before = statSync(root).mode & 0o777;
mkdirSync(root, { recursive: true, mode: 0o755 });
const after = statSync(root).mode & 0o777;
console.log({ before, after });
rmSync(root, { recursive: true, force: true });
NODE

Repository: automagik-dev/genie

Length of output: 185


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- locate file ---\n'
git ls-files | rg '^tests/support/codex-dogfood-harness\.ts$' || true

printf '\n--- relevant context ---\n'
sed -n '240,290p' tests/support/codex-dogfood-harness.ts | cat -n -v

printf '\n--- mkdtemp/mkdir usages in harness ---\n'
rg -n "mkdtempSync|mkdirSync|chmodSync|tmpdir\(" tests/support/codex-dogfood-harness.ts

Repository: automagik-dev/genie

Length of output: 248


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- locate file ---'
git ls-files | rg '^tests/support/codex-dogfood-harness\.ts$' || true

printf '%s\n' ''
printf '%s\n' '--- relevant context ---'
sed -n '240,290p' tests/support/codex-dogfood-harness.ts | cat -n -v

printf '%s\n' ''
printf '%s\n' '--- mkdtemp/mkdir usages in harness ---'
rg -n "mkdtempSync|mkdirSync|chmodSync|tmpdir\(" tests/support/codex-dogfood-harness.ts

Repository: automagik-dev/genie

Length of output: 3231


Do not rely on mkdirSync to change the mkdtemp root mode.

mkdtempSync already creates root, and mkdirSync(root, { recursive: true, mode: 0o755 }) leaves existing directories unchanged. Use chmodSync if 755 is required; otherwise remove this redundant call and keep the temporary-directory permissions private.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/support/codex-dogfood-harness.ts` at line 271, Update the
temporary-directory setup around mkdtempSync and mkdirSync: do not use mkdirSync
to change the existing root permissions. Remove the redundant mkdirSync call to
preserve private temporary-directory permissions, or replace it with chmodSync
only if mode 755 is explicitly required.

Bidirectional synchronization treated every differing shared key as a conflict,
even when tasks and wish groups carried a clear updated_at ordering. Stale
sandbox mirrors therefore required manual repair instead of converging.

Choose the entire row with the greater bigint updated_at for tasks and wish
groups and publish it to both databases. Equal-version differences and
unversioned records remain fail-closed. Directional sync and tombstones are
unchanged.

Verified with the full check suite: 3116 tests passed.
Make deletion markers versioned so a later rehire or deletion converges safely.
Use immediate task-state transactions to preserve ordering under concurrent writers.

Treat empty WAL and SHM hardlinks as inert and retain native errno details.
Fail closed on unknown CLI outcomes and count recovery cleanup failures.
Make CLI and filesystem tests portable and isolated.

Verified with bun run check: 3113 tests passed.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/lib/v5/task-state.ts (1)

1313-1331: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Return the hire from inside the transaction.

getHire runs after the immediate transaction commits. A concurrent unhireAgent between the commit and this read makes getHire return null, and the as HireRosterRow cast turns that into a null value typed as non-null. The caller then dereferences null. Move the read inside the transaction so the returned row is the row this call wrote.

🐛 Proposed fix
-  db.transaction(() => {
+  return db
+    .transaction(() => {
       const priorMarker = db.query('SELECT value FROM meta WHERE key = ?').get(tombstoneKey) as { value: string } | null;
@@
     ).run(input.wish, input.agentAdapterId, input.profile ?? null, input.worktree, now, state);
-  }).immediate();
-  return getHire(db, input.wish, input.agentAdapterId) as HireRosterRow;
+      return getHire(db, input.wish, input.agentAdapterId) as HireRosterRow;
+    })
+    .immediate();
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/v5/task-state.ts` around lines 1313 - 1331, Update the transaction
block in the hire flow to call getHire inside the immediate transaction after
the INSERT/UPSERT completes, capture and return that row from the transaction
callback, and remove the post-commit getHire call and non-null cast. Ensure the
transaction returns the row written by this call.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/v5/task-state.ts`:
- Around line 1339-1360: Define and apply a retention rule for reconciliation
tombstones created by unhireAgent, pruning meta rows for wishes that are deleted
or archived while preserving tombstones needed for active reconciliation. Anchor
the cleanup to reconciliationTombstoneMeta and the unhireAgent transaction, and
ensure retained tombstones continue to prevent stale hire records from being
reapplied.

In `@src/term-commands/v5-db-sync.ts`:
- Around line 631-634: Update the raw argument handling in the command-root flow
before handleSync so missing rawArgs is rejected rather than replaced with an
empty array, and preserve the existing process.argv-based `.slice(2)` offset
instead of deriving it from parsed `{ from: 'user' }` arguments.

---

Outside diff comments:
In `@src/lib/v5/task-state.ts`:
- Around line 1313-1331: Update the transaction block in the hire flow to call
getHire inside the immediate transaction after the INSERT/UPSERT completes,
capture and return that row from the transaction callback, and remove the
post-commit getHire call and non-null cast. Ensure the transaction returns the
row written by this call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c865d71d-9243-4cfb-af34-8855bd937bc7

📥 Commits

Reviewing files that changed from the base of the PR and between ac3a1d0 and e800563.

⛔ Files ignored due to path filters (1)
  • README.md is excluded by !*.md
📒 Files selected for processing (13)
  • scripts/release-docs.test.ts
  • src/genie.ts
  • src/lib/interactivity.ts
  • src/lib/v5/db-reconciliation.test.ts
  • src/lib/v5/db-reconciliation.ts
  • src/lib/v5/db-sync-snapshots.test.ts
  • src/lib/v5/db-sync-snapshots.ts
  • src/lib/v5/reconciliation-tombstone.ts
  • src/lib/v5/task-state.test.ts
  • src/lib/v5/task-state.ts
  • src/term-commands/v5-db-sync.test.ts
  • src/term-commands/v5-db-sync.ts
  • tests/support/codex-dogfood-harness.ts

Comment thread src/lib/v5/task-state.ts
Comment on lines 1339 to 1360
export function unhireAgent(db: Database, wish: string, agentAdapterId: string): boolean {
const res = db.query('DELETE FROM hire_roster WHERE wish = ? AND agent_adapter_id = ?').run(wish, agentAdapterId);
return res.changes > 0;
const tombstoneKey = reconciliationTombstoneMeta({ table: 'hire_roster', wish, agentAdapterId, deletedAt: 0 }).key;
return db
.transaction(() => {
const live = db
.query('SELECT hired_at FROM hire_roster WHERE wish = ? AND agent_adapter_id = ?')
.get(wish, agentAdapterId) as { hired_at: number } | null;
const priorMarker = db.query('SELECT value FROM meta WHERE key = ?').get(tombstoneKey) as {
value: string;
} | null;
const priorDeletion =
priorMarker === null ? null : parseReconciliationTombstoneMeta(tombstoneKey, priorMarker.value);
let deletedAt = BigInt(Date.now());
if (live !== null) deletedAt = maxBigInt(deletedAt, BigInt(live.hired_at) + 1n);
if (priorDeletion !== null) deletedAt = maxBigInt(deletedAt, priorDeletion.deletedAt + 1n);
const tombstone = reconciliationTombstoneMeta({ table: 'hire_roster', wish, agentAdapterId, deletedAt });
const res = db.query('DELETE FROM hire_roster WHERE wish = ? AND agent_adapter_id = ?').run(wish, agentAdapterId);
db.query('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(tombstone.key, tombstone.value);
return res.changes > 0;
})
.immediate();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔵 Trivial

Tombstones in meta grow without bound.

Each unhireAgent call writes a permanent meta row, and no path prunes them. Repeated hire/unhire cycles on the same wish reuse one key, so growth is bounded by distinct (wish, agentAdapterId) pairs rather than by call count. Plan a retention rule for wishes that are deleted or archived, so reconciliation input size stays bounded over the lifetime of a repository.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/v5/task-state.ts` around lines 1339 - 1360, Define and apply a
retention rule for reconciliation tombstones created by unhireAgent, pruning
meta rows for wishes that are deleted or archived while preserving tombstones
needed for active reconciliation. Anchor the cleanup to
reconciliationTombstoneMeta and the unhireAgent transaction, and ensure retained
tombstones continue to prevent stale hire records from being reapplied.

Comment on lines +631 to +634
let root = command;
while (root.parent !== null) root = root.parent;
const rawArguments = (root as Command & { readonly rawArgs?: readonly string[] }).rawArgs ?? [];
handleSync(databaseA, databaseB, options, rawArguments.slice(2), dependencies);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

commander 12.1.0 rawArgs parse from user option

💡 Result:

In Commander.js v12.1.0, you can access the original, unparsed arguments passed to the program via the program.rawArgs property [1]. This property is populated during the execution of .parse() [1]. If your goal is to access arguments that were not consumed as options or command-arguments, Commander provides the following properties on the Command object: 1. program.rawArgs: The full, original array of arguments, typically including the node executable and script path (e.g., [process.argv]) [1]. 2. program.args: The remaining array of arguments that were not consumed by options or command-arguments [2][3]. 3. program.processedArgs: The processed command-argument values (after any custom processing) [4]. If you are using an action handler, you can also access the command object itself (which holds these properties) via this within a standard function declaration (not an arrow function) [5]: program.action(function { console.log(this.rawArgs); console.log(this.args); }); If you are looking to specifically handle arguments after a user option, Commander typically handles option values automatically. If you have "unknown" arguments or options, ensure you are not using .allowUnknownOption() if you want Commander to stop or error, or use it if you intend to capture them [6]. For more granular control over argument parsing, you may refer to the program.parse(argv, { from: 'user' }) syntax, which allows you to define the source of the arguments [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
fd -a 'v5-db-sync\.(ts|test\.ts)|package\.json' . | sed 's#^\./##'

echo "== target slice =="
sed -n '540,645p' src/term-commands/v5-db-sync.ts

echo "== test slice around parse async =="
if [ -f src/term-commands/v5-db-sync.test.ts ]; then
  sed -n '1,230p' src/term-commands/v5-db-sync.test.ts
fi

echo "== commander declarations/rawArgs references =="
rg -n "rawArgs|parseAsync|parse\\(" -S --glob '*.ts' --glob '!dist/**' --glob '!node_modules/**' src/term-commands/v5-db-sync.ts src/term-commands/v5-db-sync.test.ts package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null | head -200

Repository: automagik-dev/genie

Length of output: 13542


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== rejection logic =="
rg -n -A12 -B6 "rejectRepeatedOptions" src/term-commands/v5-db-sync.ts

echo "== command registration / parse paths =="
rg -n "parseAsync|parse\\(" src/term-commands src -g '*.ts' | sed -n '1,220p'

echo "== v5 import and main registration =="
rg -n "registerV5DatabaseSyncCommand|genie.ts|command\\('db'|parseAsync" src -g '*.ts' | sed -n '1,220p'

Repository: automagik-dev/genie

Length of output: 29527


🌐 Web query:

Commander.js 12.1.0 rawArgs parseAsync from user undefined program.rawArgs documentation

💡 Result:

In Commander.js, program.rawArgs provides access to the complete, unparsed array of arguments passed to the application [1][2]. While program.args contains only the arguments not consumed by options, program.rawArgs retains the original input as received by the process [3][1]. Regarding the use of { from: 'user' } with.parse or.parseAsync: When you pass an array of strings and specify { from: 'user' }, you are instructing Commander to treat the provided array as containing only the user-supplied arguments, skipping the default behavior where it expects argv to be the application binary and argv[4] to be the script path [5][6]. For example: program.parse(['--port', '80'], { from: 'user' }); Using.parseAsync is the recommended method when your command or action handlers involve asynchronous operations (e.g., promises) [5][7][6]. If you use.parse instead of.parseAsync with async code, you may encounter issues where the application exits before your asynchronous logic completes [7]. If you find that your options or arguments are "undefined," it is often because: 1. You are using an async action handler without.parseAsync [8][7]. 2. The arguments are being consumed incorrectly or the input is not being passed as expected (e.g., via npm scripts that may swallow certain flags) [8]. If you need to test for the absence of user input, you can use program.rawArgs.length as a reliable check for the number of arguments provided to the process [9][2]. Top results: [5][6][2][7]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package commander version =="
python3 - <<'PY'
import json
from pathlib import Path
paths = [Path('package.json'), Path('plugins/genie/package.json')]
for p in paths:
    if p.exists():
        data=json.loads(p.read_text())
        print(p)
        for dep in ('dependencies','devDependencies','peerDependencies'):
            if 'commander' in data.get(dep, {}):
                print(f'  {dep}: commander={data[dep]["commander"]}')
PY

echo "== local commander source rawArgs population if available =="
for dir in node_modules plugins/genie/node_modules; do
  for f in "$dir"/commander/lib/command.js "$dir"/commander/lib/command.mjs; do
    [ -f "$f" ] && { echo "--- $f"; rg -n -C 4 "rawArgs|from:'user'|from: 'user'" "$f" | sed -n '1,120p'; }
  done
done

echo "== relevant local tests for repeated options =="
rg -n -C 5 "repeated|duplicate|--json --json|once|rejectRepeatedOptions" src/term-commands/v5-db-sync.test.ts src/term-commands/v5-db-sync.ts

Repository: automagik-dev/genie

Length of output: 9318


Make the rawArgs fallback fail-closed.

rawArguments becomes [] when rawArgs is absent, so rejectRepeatedOptions accepts every duplicate option. A validation guard should reject instead of failing open. Also keep .slice(2) tied to the actual process.argv shape, because { from: 'user' } parsing would shift the window and hide duplicates in the first two arguments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/term-commands/v5-db-sync.ts` around lines 631 - 634, Update the raw
argument handling in the command-root flow before handleSync so missing rawArgs
is rejected rather than replaced with an empty array, and preserve the existing
process.argv-based `.slice(2)` offset instead of deriving it from parsed `{
from: 'user' }` arguments.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/genie-commands/__tests__/update-command-publication.test.ts`:
- Around line 49-57: Update the fixture setup around the mkdirSync and
writeFileSync calls to explicitly normalize permissions after creation: apply
0755 to every created directory and 0644 to every written file before the
archive is generated. Preserve the existing fixture paths and contents while
ensuring restrictive umasks cannot change the archived entry permissions.

In `@src/lib/v5/db-reconciliation.test.ts`:
- Line 1844: Increase the elapsed-time assertion thresholds in both
reconciliation tests around the apply flows: change the 1,000 ms bound near the
first assertion to 10,000 ms and the 500 ms bound near the second assertion to
5,000 ms, while preserving the existing failure-code and phase assertions.

In `@src/lib/v5/db-reconciliation.ts`:
- Around line 2590-2593: In the fallback return object, simplify the `code`
assignment so it directly uses the constant `'unexpected-failure'` instead of
the redundant ternary. Preserve the existing spread of `fallback` and all other
return fields.

In `@src/lib/v5/db-sync-snapshots.test.ts`:
- Around line 324-334: Widen the upper elapsed-time assertions in the affected
snapshot-sync tests, including this test and the similar test around the later
elapsed check, while keeping the existing lower bounds unchanged. Update only
the strict upper limits so the assertions continue validating the shared
deadline without imposing a fragile wall-clock ceiling.

In `@src/lib/v5/reconciliation-tombstone.ts`:
- Around line 66-72: Update parseReconciliationTombstoneMeta so unknown tables
using the v1 reconciliation-tombstone prefix are ignored rather than passed to
invalidTombstone or converted into invalid-data by reconciliationTombstones;
preserve the existing hire_roster parsing and rejection of malformed tombstone
keys.

In `@src/lib/v5/task-state.ts`:
- Around line 1313-1330: Update the hireAgent transaction around the hire_roster
upsert so every re-hire advances the reconciliation version monotonically:
include hired_at in the ON CONFLICT update using the newly computed now value.
Revise the nearby first-hire documentation and affected task-state tests to
reflect the changed contract, while preserving the existing tombstone ordering
logic.

In `@src/term-commands/v5-db-sync.ts`:
- Around line 350-365: Update summarizeBootstrapApply so the bootstrap path
keeps report.cleanupFailures only in the top-level cleanupFailures field and
sets recovery.cleanupFailures to an empty array, preventing cleanupFailureCount
from counting the same failures twice.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2c0515e2-00d7-4640-8537-ff001e1b7599

📥 Commits

Reviewing files that changed from the base of the PR and between a05a1a0 and e800563.

⛔ Files ignored due to path filters (2)
  • CLAUDE.md is excluded by !*.md
  • README.md is excluded by !*.md
📒 Files selected for processing (19)
  • scripts/release-docs.test.ts
  • src/genie-commands/__tests__/update-command-publication.test.ts
  • src/genie-commands/__tests__/update.test.ts
  • src/genie-commands/local-delivery-repair.test.ts
  • src/genie.ts
  • src/lib/agent-sync.test.ts
  • src/lib/interactivity.ts
  • src/lib/v5/db-reconciliation.test.ts
  • src/lib/v5/db-reconciliation.ts
  • src/lib/v5/db-sync-snapshots.test.ts
  • src/lib/v5/db-sync-snapshots.ts
  • src/lib/v5/reconciliation-tombstone.ts
  • src/lib/v5/task-state.test.ts
  • src/lib/v5/task-state.ts
  • src/term-commands/ui-bridge.ts
  • src/term-commands/v5-db-sync.test.ts
  • src/term-commands/v5-db-sync.ts
  • tests/support/codex-dogfood-harness.ts
  • tests/support/update-current-boundary-runner.ts

Comment on lines +49 to +57
mkdirSync(join(payload, directory), { recursive: true, mode: 0o755 });
}
writeFileSync(join(payload, '.agents', 'plugin.json'), '{}\n');
writeFileSync(join(payload, '.claude-plugin', 'marketplace.json'), '{}\n');
writeFileSync(join(payload, 'LICENSE'), 'test fixture\n');
writeFileSync(join(payload, 'VERSION'), `${version}\n`);
writeFileSync(join(payload, 'plugins', 'genie', 'plugin.txt'), 'authenticated plugin payload\n');
writeFileSync(join(payload, 'skills', 'review', 'SKILL.md'), '# Review\n');
writeFileSync(join(payload, 'templates', 'template.txt'), 'template\n');
writeFileSync(join(payload, '.agents', 'plugin.json'), '{}\n', { mode: 0o644 });
writeFileSync(join(payload, '.claude-plugin', 'marketplace.json'), '{}\n', { mode: 0o644 });
writeFileSync(join(payload, 'LICENSE'), 'test fixture\n', { mode: 0o644 });
writeFileSync(join(payload, 'VERSION'), `${version}\n`, { mode: 0o644 });
writeFileSync(join(payload, 'plugins', 'genie', 'plugin.txt'), 'authenticated plugin payload\n', { mode: 0o644 });
writeFileSync(join(payload, 'skills', 'review', 'SKILL.md'), '# Review\n', { mode: 0o644 });
writeFileSync(join(payload, 'templates', 'template.txt'), 'template\n', { mode: 0o644 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

node <<'NODE'
const { mkdtempSync, mkdirSync, writeFileSync, statSync, rmSync } = require('node:fs');
const { join } = require('node:path');
const { tmpdir } = require('node:os');

const root = mkdtempSync(join(tmpdir(), 'genie-mode-check-'));
const previous = process.umask(0o077);

try {
  const directory = join(root, 'directory');
  const file = join(directory, 'file');
  mkdirSync(directory, { recursive: true, mode: 0o755 });
  writeFileSync(file, '', { mode: 0o644 });

  console.log({
    directory: (statSync(directory).mode & 0o777).toString(8),
    file: (statSync(file).mode & 0o777).toString(8),
  });
} finally {
  process.umask(previous);
  rmSync(root, { recursive: true, force: true });
}
NODE

Repository: automagik-dev/genie

Length of output: 191


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files matching test name:"
fd -a 'update-command-publication\.test\.ts$' . || true

file="$(fd 'update-command-publication\.test\.ts$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  echo "---- outline ----"
  ast-grep outline "$file" || true
  echo "---- relevant lines 1-180 ----"
  sed -n '1,180p' "$file" | nl -ba
fi

echo
echo "Search for tarball/archive creation in this file:"
rg -n "tar|archive|createArchive|package|publish|chmod|chmodSync|mkdirSync|writeFileSync|umask" "$file" || true

Repository: automagik-dev/genie

Length of output: 808


🏁 Script executed:

#!/bin/bash
set -euo pipefail

target="src/genie-commands/__tests__/update-command-publication.test.ts"

echo "---- lines 40-120 ----"
sed -n '40,120p' "$target"

echo
echo "---- lines 120-260 ----"
sed -n '120,260p' "$target"

echo
echo "---- lines 260-420 ----"
sed -n '260,420p' "$target"

echo
echo "---- search archive/tar/chmod/umask references ----"
rg -n "tar|archive|package|publish|chmod|chmodSync|mkdirSync|writeFileSync|umask|sha256|generate|create|Release" "$target" || true

Repository: automagik-dev/genie

Length of output: 7818


Normalize release fixture permissions before archiving.

mkdirSync() and writeFileSync() only apply mode as the per-path umask, so restrictive environments create 0700/ 0600 entries. Since this fixture is archived with tar, set directory permissions after creation and file permissions after writing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/genie-commands/__tests__/update-command-publication.test.ts` around lines
49 - 57, Update the fixture setup around the mkdirSync and writeFileSync calls
to explicitly normalize permissions after creation: apply 0755 to every created
directory and 0644 to every written file before the archive is generated.
Preserve the existing fixture paths and contents while ensuring restrictive
umasks cannot change the archived entry permissions.

{ code: 'close-failed', phase: 'cleanup' },
],
});
expect(Date.now() - startedAt).toBeLessThan(1_000);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wall-clock assertions will flake on a loaded CI runner.

Line 1844 asserts the whole apply finishes within 1000 ms and line 1957 asserts within 500 ms. Both tests already assert the correct failure code and phase, which is the actual contract. The elapsed-time bound only guards against an unbounded wait, so a much larger budget still proves the point without failing when the runner stalls.

Raise both bounds, for example to 10_000 ms and 5_000 ms.

Also applies to: 1957-1957

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/v5/db-reconciliation.test.ts` at line 1844, Increase the elapsed-time
assertion thresholds in both reconciliation tests around the apply flows: change
the 1,000 ms bound near the first assertion to 10,000 ms and the 500 ms bound
near the second assertion to 5,000 ms, while preserving the existing
failure-code and phase assertions.

Comment on lines +2590 to +2593
return {
...fallback,
code: fallback.code === 'unexpected-failure' ? fallback.code : 'unexpected-failure',
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Collapse the always-true ternary.

fallback.code === 'unexpected-failure' ? fallback.code : 'unexpected-failure' evaluates to 'unexpected-failure' in both branches. The expression suggests the fallback code is sometimes preserved, but it never is. Simplify it so the intent is explicit.

♻️ Proposed simplification
-  return {
-    ...fallback,
-    code: fallback.code === 'unexpected-failure' ? fallback.code : 'unexpected-failure',
-  };
+  // Untyped throws are never attributed to a specific reconciliation code.
+  return { ...fallback, code: 'unexpected-failure' };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
...fallback,
code: fallback.code === 'unexpected-failure' ? fallback.code : 'unexpected-failure',
};
// Untyped throws are never attributed to a specific reconciliation code.
return { ...fallback, code: 'unexpected-failure' };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/v5/db-reconciliation.ts` around lines 2590 - 2593, In the fallback
return object, simplify the `code` assignment so it directly uses the constant
`'unexpected-failure'` instead of the redundant ternary. Preserve the existing
spread of `fallback` and all other return fields.

Comment on lines +324 to +334
const elapsed = Date.now() - startedAt;

expect(report).toMatchObject({
status: 'operational-failure',
failure: 'locked-operation-failed',
cleanupFailures: [],
});
expect(existsSync(target)).toBe(false);
expect(openedPaths).toEqual([reconciliationAdvisoryLockPath(source), reconciliationAdvisoryLockPath(target)]);
expect(elapsed).toBeGreaterThanOrEqual(60);
expect(elapsed).toBeLessThan(300);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Tight wall-clock upper bounds can flake on loaded CI.

elapsed < 300 allows only 100 ms of slack over the 200 ms injected advisory wait. The test at Line 417 has a similar bound. A loaded runner or a GC pause can exceed it and fail a correct implementation. The lower bounds prove the deadline sharing; consider widening only the upper bounds.

♻️ Proposed change
     expect(elapsed).toBeGreaterThanOrEqual(60);
-    expect(elapsed).toBeLessThan(300);
+    expect(elapsed).toBeLessThan(1_500);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const elapsed = Date.now() - startedAt;
expect(report).toMatchObject({
status: 'operational-failure',
failure: 'locked-operation-failed',
cleanupFailures: [],
});
expect(existsSync(target)).toBe(false);
expect(openedPaths).toEqual([reconciliationAdvisoryLockPath(source), reconciliationAdvisoryLockPath(target)]);
expect(elapsed).toBeGreaterThanOrEqual(60);
expect(elapsed).toBeLessThan(300);
const elapsed = Date.now() - startedAt;
expect(report).toMatchObject({
status: 'operational-failure',
failure: 'locked-operation-failed',
cleanupFailures: [],
});
expect(existsSync(target)).toBe(false);
expect(openedPaths).toEqual([reconciliationAdvisoryLockPath(source), reconciliationAdvisoryLockPath(target)]);
expect(elapsed).toBeGreaterThanOrEqual(60);
expect(elapsed).toBeLessThan(1_500);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/v5/db-sync-snapshots.test.ts` around lines 324 - 334, Widen the upper
elapsed-time assertions in the affected snapshot-sync tests, including this test
and the similar test around the later elapsed check, while keeping the existing
lower bounds unchanged. Update only the strict upper limits so the assertions
continue validating the shared deadline without imposing a fragile wall-clock
ceiling.

Comment on lines +66 to +72
const suffix = key.slice(RECONCILIATION_TOMBSTONE_PREFIX.length);
const separator = suffix.indexOf(':');
if (separator < 1) invalidTombstone();
const table = suffix.slice(0, separator);
const [first, second] = decodedParts(suffix.slice(separator + 1));
if (table === 'hire_roster') return { table, wish: first, agentAdapterId: second, deletedAt };
return invalidTombstone();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether any other table is ever encoded under the v1 tombstone prefix,
# and whether docs promise forward compatibility for unknown tombstone tables.
rg -n -C3 'RECONCILIATION_TOMBSTONE_PREFIX|reconciliationTombstoneMeta|reconciliation-tombstone' --type=ts
rg -n -i -C3 'tombstone' --glob '*.md'

Repository: automagik-dev/genie

Length of output: 157


🏁 Script executed:

#!/bin/bash
set -u

echo "## locate files"
fd -a 'reconciliation-tombstone|db-reconciliation' . || true

echo "## search symbols excluding binary"
rg -n -C3 'RECONCILIATION_TOMBSTONE_PREFIX|reconciliationTombstoneMeta|reconciliationTombstones|decodedParts|invalidTombstone|invalid-data' . --glob '*.ts' --glob '!node_modules/**' || true

echo "## git status/stat"
git status --short --short 2>/dev/null || true
git diff --stat 2>/dev/null || true

Repository: automagik-dev/genie

Length of output: 24729


🏁 Script executed:

#!/bin/bash
set -u

python3 - <<'PY'
from pathlib import Path
import re
from urllib.parse import quote

# Minimal read-only replication of the relevant prefix/suffix behavior.
prefix = 'genie:reconciliation-tombstone:v1:'
def encoded_parts(parts):
    import base64, json
    return base64.urlsafe_b64encode(json.dumps(parts).encode()).rstrip(b'=').decode()

def make(key_suffix):
    return f"{prefix}{encoded_parts(key_suffix.split(':', 1)[0])}:{encoded_parts(key_suffix.split(':', 1)[0])}"

samples = ['hire_roster:known:table', 'unknown_table:fresh:fresh']
for sample in samples:
    print(f"#{sample}")
    for value in [0, 'deleted', 'not-canonical-base64']:
        key = make(sample)
        print(f"  value={value}; starts_with={key.startswith(prefix)}")
        suffix = key[len(prefix):]
        sep = suffix.index(':')
        table = suffix[:sep]
        encoded_after = suffix[sep + 1:]
        print(f"  table={table}; unknown_table_rejects={table!r != 'hire_roster'}")
PY

Repository: automagik-dev/genie

Length of output: 380


🏁 Script executed:

#!/bin/bash
set -u

python3 - <<'PY'
from pathlib import Path
import re
from urllib.parse import quote

# Minimal read-only replication of the relevant prefix/suffix behavior.
prefix = 'genie:reconciliation-tombstone:v1:'
def encoded_parts(parts):
    import base64, json
    return base64.urlsafe_b64encode(json.dumps(parts).encode()).rstrip(b'=').decode()

def make(key_suffix):
    return f"{prefix}{encoded_parts(key_suffix.split(':', 1)[0])}:{encoded_parts(key_suffix.split(':', 1)[0])}"

samples = ['hire_roster:known:table', 'unknown_table:fresh:fresh']
for sample in samples:
    print(f"#{sample}")
    for value in [0, 'deleted', 'not-canonical-base64']:
        key = make(sample)
        print(f"  value={value}; starts_with={key.startswith(prefix)}")
        suffix = key[len(prefix):]
        sep = suffix.index(':')
        table = suffix[:sep]
        encoded_after = suffix[sep + 1:]
        unknown_rejects = table != 'hire_roster'
        print(f"  table={table}; unknown_table_rejects={unknown_rejects}")
PY

Repository: automagik-dev/genie

Length of output: 763


Do not reject unknown v1 tombstone tables with invalid-data.

parseReconciliationTombstoneMeta returns null only for keys outside RECONCILIATION_TOMBSTONE_PREFIX; a future genie:reconciliation-tombstone:v1: key for a new table throws Invalid reconciliation tombstone metadata, which reconciliationTombstones converts to invalid-data for the whole input. Bump the prefix to v2 for a new tombstone table, or treat unknown v1 tables as ignored instead of poisoning reconciliation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/v5/reconciliation-tombstone.ts` around lines 66 - 72, Update
parseReconciliationTombstoneMeta so unknown tables using the v1
reconciliation-tombstone prefix are ignored rather than passed to
invalidTombstone or converted into invalid-data by reconciliationTombstones;
preserve the existing hire_roster parsing and rejection of malformed tombstone
keys.

Comment thread src/lib/v5/task-state.ts
Comment on lines +1313 to +1330
db.transaction(() => {
const priorMarker = db.query('SELECT value FROM meta WHERE key = ?').get(tombstoneKey) as { value: string } | null;
const priorDeletion =
priorMarker === null ? null : parseReconciliationTombstoneMeta(tombstoneKey, priorMarker.value);
const now = Number(
priorDeletion === null ? BigInt(Date.now()) : maxBigInt(BigInt(Date.now()), priorDeletion.deletedAt + 1n),
);
if (!Number.isSafeInteger(now)) throw new Error('Roster reconciliation version exceeds the safe timestamp range.');
db.query('DELETE FROM meta WHERE key = ?').run(tombstoneKey);
db.query(
`INSERT INTO hire_roster (wish, agent_adapter_id, profile, worktree, hired_at, state)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(wish, agent_adapter_id) DO UPDATE SET
profile = excluded.profile,
worktree = excluded.worktree,
state = excluded.state`,
).run(input.wish, input.agentAdapterId, input.profile ?? null, input.worktree, now, state);
}).immediate();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

A re-hire that happens before a remote unhire is synced is silently discarded.

hired_at is the reconciliation version for hire_roster rows. db-reconciliation.ts compares live.hiredAt against tombstone.deletedAt and deletes the row when hiredAt <= deletedAt. The ON CONFLICT DO UPDATE SET list here deliberately omits hired_at, so a re-hire on a row that still exists keeps the original timestamp.

Failure sequence:

  1. Host and guest both hold the row with hired_at = 1.
  2. Guest runs unhireAgent and writes a tombstone with deletedAt = T, where T > 1.
  3. Before any sync, the host runs hireAgent with new worktree or state. The host has no local tombstone, so the ON CONFLICT branch runs and hired_at stays 1.
  4. Sync copies the guest tombstone to the host. 1 <= T, so the host row is deleted.

The user's re-hire is lost with no conflict reported. The existing test at src/lib/v5/db-reconciliation.test.ts lines 1054-1067 only covers a re-hire that happens after the tombstone was already applied locally, so it does not catch this ordering.

Advance hired_at monotonically on every re-hire, or introduce a separate roster version column so hired_at can keep its "first hire" meaning.

🐛 Proposed fix: make the reconciliation version advance on re-hire
   db.transaction(() => {
     const priorMarker = db.query('SELECT value FROM meta WHERE key = ?').get(tombstoneKey) as { value: string } | null;
     const priorDeletion =
       priorMarker === null ? null : parseReconciliationTombstoneMeta(tombstoneKey, priorMarker.value);
-    const now = Number(
-      priorDeletion === null ? BigInt(Date.now()) : maxBigInt(BigInt(Date.now()), priorDeletion.deletedAt + 1n),
-    );
+    const live = db
+      .query('SELECT hired_at FROM hire_roster WHERE wish = ? AND agent_adapter_id = ?')
+      .get(input.wish, input.agentAdapterId) as { hired_at: number } | null;
+    let version = BigInt(Date.now());
+    if (priorDeletion !== null) version = maxBigInt(version, priorDeletion.deletedAt + 1n);
+    if (live !== null) version = maxBigInt(version, BigInt(live.hired_at) + 1n);
+    const now = Number(version);
     if (!Number.isSafeInteger(now)) throw new Error('Roster reconciliation version exceeds the safe timestamp range.');
     db.query('DELETE FROM meta WHERE key = ?').run(tombstoneKey);
     db.query(
       `INSERT INTO hire_roster (wish, agent_adapter_id, profile, worktree, hired_at, state)
        VALUES (?, ?, ?, ?, ?, ?)
        ON CONFLICT(wish, agent_adapter_id) DO UPDATE SET
          profile  = excluded.profile,
          worktree = excluded.worktree,
+         hired_at = excluded.hired_at,
          state    = excluded.state`,
     ).run(input.wish, input.agentAdapterId, input.profile ?? null, input.worktree, now, state);
   }).immediate();

Note that this changes the documented "first hire timestamp survives every re-hire" contract in the doc comment at lines 1297-1304, and src/lib/v5/task-state.test.ts asserts that behaviour. If the original timestamp must be preserved, add a separate version column instead and compare that column against deletedAt in applyTombstonesToState.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/v5/task-state.ts` around lines 1313 - 1330, Update the hireAgent
transaction around the hire_roster upsert so every re-hire advances the
reconciliation version monotonically: include hired_at in the ON CONFLICT update
using the newly computed now value. Revise the nearby first-hire documentation
and affected task-state tests to reflect the changed contract, while preserving
the existing tombstone ordering logic.

Comment on lines +350 to +365
function summarizeBootstrapApply(report: MissingDatabaseBootstrapApplyReport): ApplyReport {
return {
status: report.status,
generationId: null,
recovery: {
status: report.status === 'changed' ? 'none' : 'operational-failure',
generationId: null,
restoredDatabaseIdentities: [],
failure: report.failure,
cleanupFailures: report.cleanupFailures,
},
apply: null,
failure: report.failure,
cleanupFailures: report.cleanupFailures,
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bootstrap cleanup failures are counted twice in the human summary.

summarizeBootstrapApply copies report.cleanupFailures into both cleanupFailures and recovery.cleanupFailures. cleanupFailureCount at Line 466 adds both arrays, so genie db sync prints Cleanup failures: 2 for a single bootstrap cleanup failure. The exit code is unaffected because the branch tests > 0, but the printed count misleads an operator sizing the leak.

Leave the recovery array empty for the bootstrap path, since the bootstrap report has no separate recovery phase.

🐛 Proposed fix
     recovery: {
       status: report.status === 'changed' ? 'none' : 'operational-failure',
       generationId: null,
       restoredDatabaseIdentities: [],
       failure: report.failure,
-      cleanupFailures: report.cleanupFailures,
+      cleanupFailures: [],
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function summarizeBootstrapApply(report: MissingDatabaseBootstrapApplyReport): ApplyReport {
return {
status: report.status,
generationId: null,
recovery: {
status: report.status === 'changed' ? 'none' : 'operational-failure',
generationId: null,
restoredDatabaseIdentities: [],
failure: report.failure,
cleanupFailures: report.cleanupFailures,
},
apply: null,
failure: report.failure,
cleanupFailures: report.cleanupFailures,
};
}
function summarizeBootstrapApply(report: MissingDatabaseBootstrapApplyReport): ApplyReport {
return {
status: report.status,
generationId: null,
recovery: {
status: report.status === 'changed' ? 'none' : 'operational-failure',
generationId: null,
restoredDatabaseIdentities: [],
failure: report.failure,
cleanupFailures: [],
},
apply: null,
failure: report.failure,
cleanupFailures: report.cleanupFailures,
};
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/term-commands/v5-db-sync.ts` around lines 350 - 365, Update
summarizeBootstrapApply so the bootstrap path keeps report.cleanupFailures only
in the top-level cleanupFailures field and sets recovery.cleanupFailures to an
empty array, preventing cleanupFailureCount from counting the same failures
twice.

automagik-genie pushed a commit that referenced this pull request Sep 7, 2026
Capture the roster row in the upsert with RETURNING, preserving defaults and
original hired_at while eliminating the post-write read race. Exercise two
processes hiring and removing the same row against one real SQLite database.

Reauthored from the hireAgent race identified in Liraz Siri's PR #2737 review.
Validated with the full gate (2031 pass, 1 skip, 0 fail) and isolated API plus
built CLI backup/import dogfood.

Co-authored-by: Felipe Rosa <felipe@namastex.ai>
automagik-genie pushed a commit that referenced this pull request Sep 7, 2026
Port the surviving fixture creation modes from Liraz Siri's PR #2737,
commit 9711b15. Keep retired agent-sync
and obsolete plugin payload hunks absent. Process umask still applies.

Independent review SHIP; surviving suites pass under default and 027 umasks
with 12 tests and 73 assertions each. B2 selection determinism is already
fixed on main by 01598ec.

Co-authored-by: Felipe Rosa <felipe@namastex.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants