Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions docs/effort-graph/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ The Effort Graph is **built on top of** Flatbread's content-layer vocabulary (se

**Operational provenance** (which session produced this, which agent, which model, which DAG run) is captured as **frontmatter fields** on these primitives, not as peer collections. The durable transcript record lives next to the graph under `.flatbread/artifacts/` (see [`packages/proof/README.md`](../../packages/proof/README.md) §Artifact Output).

**Committed generation.** The opaque journal generation token returned by an Effort Graph mutation. It is published only after the full save group has produced a committed live schema (the `CommittedGenerationPublisher` seam — not to be confused with `EffortGraphIndex`, the plan-time read interface). It is a different counter from any process-local live-schema generation; the committed-generation bridge maps the former to the latter for strict readers.

---

### Effort
Expand Down
50 changes: 50 additions & 0 deletions docs/effort-graph/adr/0008-committed-generation-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ADR-0008: Committed-generation bridge

Status: Accepted

## Context

ADR-0003 requires the writer to expose a monotonic generation token and an
opt-in strict read. ADR-0004 requires generation publication only after
reindex and live schema swap. Both halves existed (the journal protocol and
the live reloader), but the wire between them did not: `.journal/generation.json`
could advance while `LiveSchemaReloader.generation` never moved. Journal tokens
are durable per root; live generations are process-local across all content.
Equating their values is false after restart and whenever unrelated content
changes.

## Decision

The writer now uses `CommittedGenerationPublisher`, renamed from
`EffortGraphIndexer` to separate it from the plan-time `EffortGraphIndex`.
The live adapter maps relative paths to absolute paths, awaits
`notifyChanged({ source: 'writer' })`, and throws on rejected candidates. This
existing callback gate is the publish gate; the journal protocol is unchanged.
The bridge privately maps journal token J to live generation L, and strict
readers require both a live commit and durable publication, with an explicit
timeout escape hatch.

A disk-backed, journal-aware `ReindexBarrier` defers watcher paths named by
uncommitted intents, releases on the committed marker or rollback removal,
never waits on the reloader or publisher, fails closed on malformed intents,
and bounds its wait so an orphaned transaction cannot stall the serialized
reindex queue forever.

The Flatbread composition root activates on structural detection of the
complete six-entry `effortGraphContent(root)` shape (paths and refs), attaches
the bridge, attempts non-fatal boot recovery before listening, and exposes
`RunningGraphqlServer.effortGraph`. Flatbread takes a runtime workspace
dependency on effort-graph; effort-graph keeps core type-only, and core learns
no journal semantics.

## Consequences

A returned mutation token names a generation whose live schema commit already
completed, providing strict same-process read-your-writes. An out-of-process
writer publishes normally with the no-op publisher; the server watcher observes
its files after commit or rollback and those reads are EVENTUAL, not strict.
A dead external writer can defer intersecting watcher work until lease-safe
recovery; bounded barrier waits convert that to a logged rejected batch rather
than a stalled queue. The watcher may rebuild files the writer just published;
whole-file reads and serialization make that safe. This completes the
ADR-0003/0004 committed-generation contract.
104 changes: 104 additions & 0 deletions packages/effort-graph/src/__tests__/journal-barrier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import test from 'ava';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createJournalReindexBarrier } from '../journalBarrier.js';
import {
EffortGraphBarrierTimeoutError,
EffortGraphCorruptJournalError,
} from '../errors.js';

async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'eg-barrier-'));
const txn = join(root, '.journal', 'txns', 'txn');
await mkdir(txn, { recursive: true });
await writeFile(
join(txn, 'intent.json'),
JSON.stringify({
writes: [{ relativePath: 'efforts/a.md' }],
})
);
return { root, txn, path: join(root, 'efforts', 'a.md') };
}

test.serial(
'defers watcher paths named by an uncommitted intent and releases on the committed marker',
async (t) => {
const { root, txn, path } = await fixture();
const pending = createJournalReindexBarrier({
rootDir: root,
pollIntervalMs: 5,
}).waitUntilReadable([path]);
await new Promise((resolve) => setTimeout(resolve, 20));
let done = false;
void pending.then(() => (done = true));
t.false(done);
await writeFile(join(txn, 'committed'), '');
await pending;
t.pass();
}
);

test.serial(
'releases after rollback removes the uncommitted transaction',
async (t) => {
const { root, txn, path } = await fixture();
const pending = createJournalReindexBarrier({
rootDir: root,
pollIntervalMs: 5,
}).waitUntilReadable([path]);
await rm(txn, { recursive: true, force: true });
await pending;
t.pass();
}
);

test.serial(
'does not defer unrelated paths or committed transactions',
async (t) => {
const { root, txn } = await fixture();
await writeFile(join(txn, 'committed'), '');
await createJournalReindexBarrier({ rootDir: root }).waitUntilReadable([
join(root, 'other.md'),
]);
t.pass();
}
);

test.serial(
'maps relative intent paths against absolute watcher paths',
async (t) => {
const { root, path } = await fixture();
const pending = createJournalReindexBarrier({
rootDir: root,
pollIntervalMs: 5,
}).waitUntilReadable([path]);
await writeFile(join(root, '.journal', 'txns', 'txn', 'committed'), '');
await pending;
t.pass();
}
);

test.serial('fails closed on a malformed active intent', async (t) => {
const { root, txn, path } = await fixture();
await writeFile(join(txn, 'intent.json'), '{broken');
await t.throwsAsync(
createJournalReindexBarrier({ rootDir: root }).waitUntilReadable([path]),
{ instanceOf: EffortGraphCorruptJournalError }
);
});

test.serial(
'bounded wait rejects after maxWaitMs for an orphaned transaction',
async (t) => {
const { root, path } = await fixture();
await t.throwsAsync(
createJournalReindexBarrier({
rootDir: root,
pollIntervalMs: 2,
maxWaitMs: 10,
}).waitUntilReadable([path]),
{ instanceOf: EffortGraphBarrierTimeoutError }
);
}
);
Loading
Loading