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
97 changes: 97 additions & 0 deletions .agents/docs/adr/0007-sync-source-background-launch-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# ADR 0007: Public `sync_source` Background Launch Contract

## Status

accepted

## Date

2026-06-15

## Context

`MCPContentSearch` originally treated `sync_source(source_id)` as a blocking
end-to-end sync call. That worked for short source fetches, but it breaks down
for larger configured sources such as Notion when the MCP client, transport, or
caller timeout cancels the request before the sync finishes. In the observed
Notion failure, the configured `NOTION_API_KEY` was valid and live requests were
still returning `200 OK`, but the client disconnected during a long sync and
left the SQLite job status in `running`.

The repo already uses SQLite metadata as the authoritative source/job state
store under ADR 0002, and existing MCP clients already depend on the retained
`sync_source` and `get_sync_status` tool names from ADR 0004/ADR 0006 scope.
The fix therefore needs to preserve the current MCP surface while changing the
public completion semantics so long-running syncs survive request lifetime.

## Decision

Keep two distinct sync boundaries:

- Internal direct callers use `IngestionService.sync_source(source_id)` as the
blocking execution path when they start a new sync themselves or when they
can join a same-process local background task for that source. If another
owner already holds the running SQLite job and there is no joinable local
task, the direct path returns that current running job instead of pretending
it can block on foreign in-flight work.
- Public MCP callers use `IngestionService.start_sync_source(source_id)` through
the `sync_source` tool as an immediate-return background launcher.

Under this contract, public MCP `sync_source(source_id)`:

- starts a new background sync job or reuses the active running job for the
same source
- returns the current job payload immediately, typically with `status=running`
- requires callers to poll `get_sync_status(source_id)` for terminal
`succeeded` or `failed` completion
- must not silently fall back to the blocking path when a background launcher
is unavailable

Background execution still owns the full fetch, chunk, index, metadata commit,
and stale-cleanup lifecycle. Cancellation or early background failure must
reconcile the job out of `running` through SQLite metadata so later retries are
not blocked by stuck state. When a direct same-process caller encounters a
just-cancelled local background task, it may surface that reconciled failed job
once instead of opening fresh duplicate work immediately, but only while that
same cancelled job is still the latest authoritative SQLite job for the
source. This exception is for cancelled-local handoff only; a successfully
completed background sync must not suppress the next fresh direct sync, and a
newer foreign retry must override the stale local cancelled handoff.

## Consequences

- MCP tool names remain stable: callers keep using `sync_source` and
`get_sync_status` instead of introducing a new public launcher tool.
- Client-facing docs, architecture notes, and retained MCP contract tests must
describe and verify the immediate-return plus polling behavior.
- Review gates must treat public `sync_source` as a contract boundary change,
not just an implementation detail.
- Background-task cancellation, early startup failure, and overlapping
same-source launch reuse need focused regression coverage because they can
otherwise leave stale `running` jobs behind.
- Direct service-level tests and internal call sites can continue to rely on the
blocking `IngestionService.sync_source()` path when they start the work
themselves or join a same-process local background task. They must not assume
cross-process blocking over a foreign running job that only exists in SQLite
metadata.

## Alternatives Considered

- Keep public `sync_source` blocking and only raise client timeouts: rejected
because the real failure mode was a cancelled long-running sync leaving
incorrect SQLite state.
- Add a brand-new MCP tool such as `start_sync_source`: rejected because the
current retained MCP surface can support the new behavior without expanding
the public tool set.
- Convert every sync caller to background-only semantics, including direct
service callers: rejected because internal focused tests and direct service
flows still benefit from a blocking path with immediate terminal state.

## Related

- `.agents/docs/architecture.md`
- `.agents/docs/adr/0002-contextwiki-metadata-and-citation-store.md`
- `.agents/docs/adr/0004-contextwiki-phase-b-connectors.md`
- `.agents/docs/adr/0006-slim-mcp-core-scope.md`
- `docs/contextwiki-core-understanding.md`
- `docs/plan/2026-06-15-notion-cancel-sync-stuck.md`
1 change: 1 addition & 0 deletions .agents/docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ File names should be numbered and descriptive:
| [0004](0004-contextwiki-phase-b-connectors.md) | accepted, website/docs superseded by [0006](0006-slim-mcp-core-scope.md) | ContextWiki Phase B GitHub connector, retained Obsidian local-vault connector, and superseded website/docs connector |
| [0005](0005-contextwiki-auto-wiki-llm-synthesis.md) | superseded by [0006](0006-slim-mcp-core-scope.md) for current scope | Historical ContextWiki Auto Wiki LLM synthesis boundary |
| [0006](0006-slim-mcp-core-scope.md) | accepted | Slim MCP core scope |
| [0007](0007-sync-source-background-launch-contract.md) | accepted | Public MCP `sync_source` background-launch contract and internal blocking execution split |

## When to Add or Update ADRs

Expand Down
5 changes: 4 additions & 1 deletion .agents/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,16 @@ Source sync flow:

```text
sync_source
-> IngestionService
-> IngestionService.start_sync_source() for MCP callers
-> SourceRegistry connector lookup
-> MetadataStore source registration and sync job guard
-> immediate running-job payload returned to caller
-> background IngestionService worker fetch/index lifecycle
-> Notion, Tistory, GitHub, or Obsidian connector fetch
-> DocumentChunker
-> ContentIndexer and Chroma collection
-> MetadataStore SQLite source/job/document/chunk/tombstone metadata
-> get_sync_status reads terminal completion
```

Retained sync safety rule:
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,6 @@ vector and metadata stores, then returns verified, citation-backed context.
[ Verified Context ]
```

- **Chroma** handles semantic retrieval.
- **SQLite** tracks document lifecycle and citation validity.
- Only chunks still active in SQLite are returned as evidence.

---

## MCP Tools
Expand Down
9 changes: 7 additions & 2 deletions api/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,16 @@ async def list_sources() -> dict:

@mcp.tool()
async def sync_source(source_id: str) -> dict:
"""특정 source incremental sync 실행"""
"""특정 source sync를 시작하거나 기존 running job을 재사용한다."""
if ingestion_service is None:
return {"status": "error", "message": "ingestion service is not configured"}
if not hasattr(ingestion_service, "start_sync_source"):
return {
"status": "error",
"message": "ingestion service does not support background sync launch",
}
try:
job = await ingestion_service.sync_source(source_id)
job = await ingestion_service.start_sync_source(source_id)
return _safe_sync_job_payload(job)
except Exception as exc:
message = safe_error_message(exc)
Expand Down
Loading
Loading