Verified revamp Phase 4-7 code citations against surfsense_backend and corrected drift: - automations have NO delivery/notification path and automation_runs.output is never written; CI alerts wire to the separate app/notifications system (NotificationService.create_notification, Zero-synced) - automation PENDING-gate is non-atomic (no UPDATE...WHERE status=pending); per-Tracker lock is the primary concurrency guard, not belt-and-suspenders - folder upload uses root_folder_id (not destination_folder); KB folder scoping goes via referenced_document_ids -> SearchScope.document_ids (no folder_id search filter) - billable predicate is 'SUCCESS and outcome.result' duplicated per caller; to be single-sourced in the 04a executor Confirmed accurate: schedule selector (FOR UPDATE SKIP LOCKED/next_fire_at/self-heal/catchup=False/croniter), AutomationRun model, format_to_structured_document(exclude_metadata=True), MCP routing gap (constants.py), connector enum, MANUAL trigger placeholder. Added dated verification stamp to revamp/00-overview.md. Also reconciled 03b/03c/umbrella wording. Co-authored-by: Cursor <cursoragent@cursor.com>
40 KiB
Phase 6 — Pipeline execution + scheduling (run engine, crawl billing, blob, chat context)
❌ SUPERSEDED (2026-06-30)
This entire phase is dropped. Per the Architecture correction in
00-umbrella-plan.md, there is no pipeline run engine or scheduler. The existing automations system already runs the full chat agent (with the nativeweb_search/scrape_webpagetools) on cron/event triggers and persistsautomation_runs— which is exactly what this phase was building. Retained for historical context only — do not implement it. Its still-relevant ideas survive in the canonical revamp: (1) billing crawls done outside chat → now handled at the capability executor (revamp/04a), so recurring/automation runs meter correctly; (2) the refresh hot loop (crawl → diff → judge → append) →revamp/05b-intelligence.md, writing the Timeline (revamp/05a) — notautomation_runs.output; (3) recurrence + "run now" →revamp/06-triggers.md(reuse automations via a CI action). Thebill=Falseseam andindex_crawled_urlsfolder_id/statsparams described below are not needed (no pipeline path).
Part of the CI Pivot MVP. See
00-umbrella-plan.md(Phase 6). Precondition: Phase 5 (05-pipelines-model.md) live —pipelines/pipeline_runstables, schemas, Zero, CRUD +/run(currently enqueues a stubrun_pipeline(run_id)). Depends on03a-crawler-core.md(crawl_urlSUCCESS signal +crawls_succeededcounter) and03c-crawl-billing.md(WebCrawlCreditService). Sibling ahead:07-upload-pipeline-kb.md(uploads-as-pipeline).
Implementation note (post-rename). Phases 1–2 are SHIPPED, so the live code already uses
workspace_id/Workspace/workspaces. Citations below that still showsearch_space_id/SearchSpace/searchspacesare pre-rename — substitute theworkspace_*equivalent and grep the new name. New code in this plan uses the canonicalworkspace_*names (snippets below updated; e.g. theget_pipeline_runstool's dep key isworkspace_id, matching the renamed deps dict). Locate code by symbol/grep, not the absolute line numbers cited here (the rename + Phases 3–5 shift them).
Objective
Make pipelines actually run. Phase 5 shipped the data model + a stub task; this phase fills in:
- Run engine — turn a
PipelineRunrow into work: resolve the pipeline + connector, re-check eligibility, acquire a lock, crawl the source (WebURL crawler = the only MVP executor), optionally save to the KB destination folder, persist a raw-result blob otherwise, write the run record (status/timing/counts/error). - Crawl billing wiring (carry-over from
03c) — billcrawls_succeededto the workspace owner viaWebCrawlCreditServicefor both KB-save branches (asave_to_kb=falserun must not crawl for free); recordcharged_microson the run for idempotency. - Scheduling — a Celery Beat tick that fires due
pipelines.next_scheduled_at(cron), modeled on the automations schedule selector (cron +FOR UPDATE SKIP LOCKED+ self-heal). Plus the de-dup guard Phase 5 deferred (a pipeline over a connector disables that connector's own periodic indexing). - Chat-agent context — a read-only main-agent tool (
get_pipeline_runs) exposing recent run history scoped to the active workspace (umbrella default = tool).
This is backend-only; the Pipelines UI is deferred to the frontend umbrella.
Locked decisions (MVP)
| Concept | Decision |
|---|---|
| MVP executor | WebURL crawler only. connector.connector_type == WEBCRAWLER_CONNECTOR. Any other Type-1 type (GDrive/OneDrive/Dropbox) → run fails cleanly with "executor not implemented for <type>" (their connector-level periodic path still works; pipelines over them are a Phase-7+ concern). connector_id IS NULL (Phase-7 Uploads) → not run by this engine (uploads register their own run in Phase 7). |
| Run-engine shape | Mirror automations: thin Celery task → run_async_celery_task → async execute_pipeline_run(session, run_id) (automations/tasks/execute_run.py:19-33). PENDING-gated + idempotent terminal no-op (automations/runtime/executor.py:29-30). Status lifecycle pending → running → succeeded/failed with started_at/finished_at (mirrors repository.mark_running/mark_succeeded/mark_failed). |
| KB-save path | Reuse index_crawled_urls (the battle-tested 2-phase indexer) for save_to_kb=true, extended with a folder_id param (set on created/updated Documents → destination folder). save_to_kb=true + NULL folder → root (existing behaviour; documents.folder_id nullable). |
| Non-KB path | save_to_kb=false → a fetch-only crawl loop (no Document rows): crawl each URL, collect {url, content, metadata}, persist as one JSON blob via file_storage backend, store result_blob_key on the run. (The 2-phase indexer is Document-centric and cannot "not persist"; a separate small loop is cleaner than bending it.) |
| Billing ownership | Run engine owns billing for the pipeline path (pre-check on len(urls) + charge on crawls_succeeded + record charged_micros), calling the crawler with billing suppressed (bill=False). The connector /index + connector-periodic paths keep 03c's in-indexer billing (bill=True). This keeps charged_micros run-idempotency clean and bills both KB branches identically. (03c coordination — see below.) |
| Scheduler | New pipeline_schedule_select Beat task modeled on automations/triggers/builtin/schedule/selector.py (cron, FOR UPDATE SKIP LOCKED, self-heal of NULL next_scheduled_at), not the simpler connector frequency_minutes checker — because Phase 5 chose schedule_cron. Reuses the existing croniter util (automations/triggers/builtin/schedule/cron.py). |
| Schedule timezone | Cron needs a timezone (compute_next_fire_at(cron, timezone, …)). Phase 5's model has only schedule_cron. Add schedule_timezone VARCHAR NOT NULL DEFAULT 'UTC' to pipelines (small additive amendment to 05 — see "Required 05 amendment"). Matches how automations store cron and timezone (schedule/selector.py:86-89). |
| Concurrency | Per-pipeline Redis lock + (when connector-backed) the existing connector lock utils/indexing_locks.py so a pipeline run and any residual connector index can't double-crawl the same connector. |
| Chat context | A main-agent tool get_pipeline_runs (registry pattern, main_agent/tools/registry.py:85-102), opening its own shielded_async_session() (like KnowledgeTreeMiddleware) and scoping to the build-time workspace_id. Always-on middleware injection is deferred (token cost; tool is on-demand). |
| Code location | New app/pipelines/ package: engine.py (execute_pipeline_run), tasks.py (Celery run_pipeline + pipeline_schedule_select), scheduler.py (the tick), storage.py (blob key). The Phase-5 stub run_pipeline is replaced by the real task here; the /run route + scheduler enqueue it. (ORM stays in db.py per Phase 5; only the engine is a package, mirroring app/automations/.) |
Current state (cited)
Run-engine precedent (automations)
- Task wrapper:
automations/tasks/execute_run.py:19-33—@celery_app.task(name=..., bind=True)→run_async_celery_task(lambda: _impl(run_id));_implopensget_celery_session_maker()()and callsexecute_run(session, run_id), rolling back + re-raising on failure. - Launch:
automations/dispatch/launch.py:43-60— create the child rowstatus=PENDING,session.add+commit+refresh, thentask.apply_async(args=[run.id], time_limit=…). (Phase 5's/runalready does the create+enqueue; this phase makes the task real.) - Executor lifecycle:
automations/runtime/executor.py:23-75—load run; if run.status != PENDING: return(idempotent terminal no-op,:29-30);mark_running+commit(:46-47); on bad snapshot →mark_failed+ commit (:35-44); terminalmark_succeeded/mark_failedeachcommit.
The MVP executor: webcrawler_indexer.index_crawled_urls
- Signature
webcrawler_indexer.py:44-53:(session, connector_id, search_space_id, user_id, start_date=None, end_date=None, update_last_indexed=True, on_heartbeat_callback=None) -> (int, str|None). - Reads URLs from
connector.config["INITIAL_URLS"]viaparse_webcrawler_urls(:118-119). - Creates
Documentrows with NOfolder_id(:222-239) → today everything lands at workspace root. Updates existing docs in place. Crawls viacrawler.crawl_url(url)(:297;03achanges this signature/return). Tracksdocuments_indexed/documents_updated/ counts; final commit at:432; returns(total_processed, error)(:477). - No billing today —
03cadds the owner pre-check +charge_creditsinside this function (03c§2). This phase adds abill: bool = Trueseam so the pipeline path can suppress it (plusfolder_id/urlsparams + astatsout-param — see "Crawler changes"; the positional return is left unchanged so the shared wrapper keeps working). - Connector entrypoint chain (the path the pipeline engine bypasses, because it's notification-centric, not run-centric):
connector_tasks.py:418-454(index_crawled_urls_task→_index_crawled_urls→run_web_page_indexing) → a_run_indexing_with_notifications(...)call site (e.g. the WebCrawler one atsearch_source_connectors_routes.py:~2524). The wrapper itself is defined at:1296(acquires the connector lock at:1334, length-unpacks the indexer return at:1503).
KB-ingest service (folder support already exists there)
IndexingPipelineService(indexing_pipeline/indexing_pipeline_service.py:82) already threads a destination folder viaConnectorDocument.folder_id(:273-274,:301-302,:326). But the webcrawler indexer does not use this service — it writesDocuments directly. So the umbrella's "route throughIndexingPipelineServiceinto the destination folder" is accurate for file/Composio sources, not the crawler. For the WebURL MVP, the minimal correct move is to threadfolder_idintoindex_crawled_urls(not refactor the crawler ontoIndexingPipelineService). Unifying them is a post-MVP cleanup.
Scheduler precedent (cron) + Beat registration
- Selector
automations/triggers/builtin/schedule/selector.py: tick (:47-65) → self-heal NULLnext_fire_at(:68-98) → claim due rowsFOR UPDATE SKIP LOCKED+ advance viacompute_next_fire_at+ setlast_fired_at(:101-150) →_start_onelaunches each (:153-183)._TICK_BATCH=200cap (:35). - Cron util
automations/triggers/builtin/schedule/cron.py:validate_cron(cron, timezone)(:15) andcompute_next_fire_at(cron, timezone, *, after)(:28, returns UTC). Requires a timezone → drives the 05 amendment. - Beat source
automations/triggers/builtin/schedule/source.py:14-20— aBEAT_SCHEDULEdict (crontab(minute="*")) merged into the app atcelery_app.py:323(**SCHEDULE_BEAT_SCHEDULE). The app's beat dict iscelery_app.py:268-324; the workerincludelist is:180-198; task routing (index_crawled_urls → CONNECTORS_QUEUE) is:237-255(:246). - Connector meta-scheduler we coexist with:
schedule_checker_task.py:17scansSearchSourceConnectorwhereperiodic_indexing_enabled AND next_scheduled_at <= now(:33-39), with a Redis-lock guardis_connector_indexing_locked(:93). Untouched; the pipeline tick is a sibling.
Locks, storage, chat tooling
- Locks
utils/indexing_locks.py:acquire_connector_indexing_lock(connector_id)->bool(:24),release_…(:37),is_…_locked(:43), keyedindexing:connector_lock:{id}, TTLCONNECTOR_INDEXING_LOCK_TTL_SECONDS. - Storage
file_storage/service.py:39,46:get_storage_backend()(fromfile_storage/factory.py) →backend.put(key, data, content_type=…)/open_stream(key)/delete(key). Keys are built per-purpose (file_storage/keys.py:11,build_document_file_key). For runs we add a sibling key builder (nodocument_filesrow — runs aren't documents). - Tool registry
main_agent/tools/registry.py:85-102—_MAIN_AGENT_TOOL_FACTORIES: {name: (factory, required_dep_names)};build_main_agent_tools(deps, …)resolves deps + builds (:105-147). Display metadata lives inshared/tools/catalog; the main-agent name list inmain_agent/tools/index(per the registry docstring:1-14). Read-only DB pattern for a chat surface:KnowledgeTreeMiddlewareopensshielded_async_session()and filters by its build-timesearch_space_id(knowledge_tree/middleware.py:199-206). - Workspace owner (billing target):
SearchSpace.user_id(03c§2.1 resolves the owner with a directselect SearchSpace.user_id where id == search_space_id).
Target design
1. execute_pipeline_run(session, run_id) — the engine (app/pipelines/engine.py)
load run (pipeline + connector eager); if run is None: raise
if run.status != PENDING: return # status gate; also no-ops acks_late redelivery (executor.py:29-30)
pipeline = run.pipeline
mark_running(run): status=RUNNING, started_at=now; commit
# ^ committed BEFORE any crawl/charge → a redelivered/duplicate task sees RUNNING and returns (executor.py:46-47)
pipeline_lock = connector_lock = False
connector = None
try:
# --- guards: fail cleanly, never crash the worker (Phase-5 hand-offs (a)/(b)) ---
if pipeline.connector_id is None: # Uploads pipeline — Phase 7, not here
return fail(run, "uploads pipeline has no fetch executor")
connector = pipeline.connector
if not is_pipeline_eligible(connector.connector_type): # 04a runtime re-check (strand guard)
return fail(run, f"connector type {connector.connector_type} no longer eligible")
if connector.connector_type != WEBCRAWLER_CONNECTOR:
return fail(run, f"executor not implemented for {connector.connector_type}")
owner_id = workspace_owner_id(session, pipeline.workspace_id) # select Workspace.user_id (03c §2.1)
actor_user_id = str(pipeline.created_by_id or owner_id) # Phase-5 hand-off (b)
urls = parse_webcrawler_urls(pipeline.config.get("INITIAL_URLS") or connector.config.get("INITIAL_URLS"))
if not urls:
return fail(run, "no URLs to crawl")
# --- concurrency: take both; release only what we took (Phase-5 hand-off (c)) ---
pipeline_lock = acquire_pipeline_indexing_lock(pipeline.id)
if not pipeline_lock:
return fail(run, "a run for this pipeline is already in progress")
connector_lock = acquire_connector_indexing_lock(connector.id)
if not connector_lock:
return fail(run, "connector is currently indexing; retry later")
# --- billing pre-flight (run-level; 03c statics). len(urls) is a safe upper bound. ---
svc = WebCrawlCreditService(session)
if WebCrawlCreditService.billing_enabled() and run.charged_micros is None:
await svc.check_credits(owner_id, len(urls)) # InsufficientCreditsError → except → fail("out of crawl credit")
# --- fetch ---
if pipeline.save_to_kb:
stats = {} # out-param sink — NON-breaking (see §3)
await index_crawled_urls(session, connector.id, pipeline.workspace_id, actor_user_id,
urls=urls, folder_id=pipeline.destination_folder_id,
bill=False, stats=stats, update_last_indexed=True)
documents_indexed = stats["documents_indexed"]
crawls_attempted = stats["crawls_attempted"]
crawls_succeeded = stats["crawls_succeeded"]
result_blob_key = None
else:
outcomes = await crawl_urls_fetch_only(urls) # list[dict] from CrawlOutcome (03a)
crawls_attempted = len(urls)
crawls_succeeded = sum(1 for o in outcomes if o["status"] == "success")
documents_indexed = 0
result_blob_key = await persist_run_blob(run, outcomes)
# --- charge: stamp run + add audit, THEN charge (its single commit flushes all three atomically) ---
if WebCrawlCreditService.billing_enabled() and crawls_succeeded > 0 and run.charged_micros is None:
run.charged_micros = WebCrawlCreditService.successes_to_micros(crawls_succeeded)
record_token_usage(session, usage_type="web_crawl", workspace_id=pipeline.workspace_id,
user_id=owner_id, cost_micros=run.charged_micros,
call_details={"urls": len(urls), "successes": crawls_succeeded,
"pipeline_id": pipeline.id, "run_id": run.id},
message_id=None) # add only — does not commit (03c §2.3)
await svc.charge_credits(owner_id, crawls_succeeded) # debits + COMMITS run+audit+balance (03c §2.4)
mark_succeeded(run): status=SUCCEEDED, finished_at=now,
documents_indexed, crawls_attempted, crawls_succeeded, result_blob_key; commit
except InsufficientCreditsError:
return fail(run, "out of crawl credit") # run is RUNNING→FAILED; no debit happened
except Exception as e:
return fail(run, {"message": str(e), "type": type(e).__name__})
finally:
if connector_lock: release_connector_indexing_lock(connector.id)
if pipeline_lock: release_pipeline_indexing_lock(pipeline.id)
fail(run, err) = set status=FAILED, finished_at=now, error={...}, commit, return — so the finally still runs and releases only the locks actually taken. connector.id in finally is reached only when connector_lock is True, which implies connector was resolved. Idempotency rests on the status gate, not charged_micros: mark_running commits RUNNING before any crawl/charge, so an acks_late redelivery (celery_app.py:228-229) or a manual re-enqueue sees RUNNING/terminal and returns. charged_micros is the belt-and-suspenders guard reserved for a future stuck-RUNNING re-driver (none in MVP).
Notes:
workspace_owner_id=select Workspace.user_id where id == workspace_id(03c§2.1 pattern).is_pipeline_eligibleis the 04a registry helper — re-checked here at run time (a deploy can flip a type toMIGRATING; the create-time check in Phase 5 doesn't protect the row forever).- URL source: per-pipeline
config["INITIAL_URLS"]overrides the connector's (theconfigJSONB "per-pipeline overrides" Phase 5 reserved); fall back to the connector'sINITIAL_URLS.
2. Celery tasks (app/pipelines/tasks.py)
@celery_app.task(name="run_pipeline", bind=True)
def run_pipeline(self, run_id: int) -> None:
return run_async_celery_task(lambda: _run(run_id)) # execute_run.py:19-22 shape
async def _run(run_id: int) -> None:
async with get_celery_session_maker()() as session:
try:
await execute_pipeline_run(session, run_id)
except Exception:
logger.exception("pipeline run %d failed unexpectedly", run_id)
await session.rollback(); raise
run_pipelinereplaces the Phase-5 stub of the same name; Phase 5's/runroute already enqueuesrun_pipeline(run.id)(no route change needed).- Routing/registration: ensure
"app.pipelines.tasks"is in theincludelist (celery_app.py:180-198) — Phase 5 already added it for the stub; no-op if present — and route"run_pipeline": {"queue": CONNECTORS_QUEUE}(it crawls — same queue asindex_crawled_urls,celery_app.py:246). Give theapply_async/delayatime_limit(the connectors queue already has an 8h hard cap,celery_app.py:219).
3. Crawler changes — index_crawled_urls (coordination with 03a/03c)
Four additive params, all default-compatible with the existing connector path:
folder_id: int | None = None— set on each createdDocument(:222-239) and on the existing-doc update branch, so KB-save lands in the destination folder. DefaultNone= root (today's behaviour).urls: list[str] | None = None— when provided, crawl this list instead of re-readingconnector.config["INITIAL_URLS"](:118-119). Required for the per-pipelineconfig["INITIAL_URLS"]override (a Phase-5configcapability) and to keep the engine'slen(urls)billing pre-check consistent with what's actually crawled. DefaultNone= read connector config (today's behaviour).bill: bool = True— gate03c's in-indexercheck_credits+charge_credits+record_token_usage. The pipeline engine callsbill=Falseand bills at the run level; the connector/index+ periodic paths keepbill=True. (Small addition to03c's §2 wiring — see "Cross-plan coordination".)stats: dict | None = None— an out-param sink, NOT a return-shape change. When provided, the indexer populatesstats["documents_indexed"|"crawls_attempted"|"crawls_succeeded"]. Do NOT widen the return tuple: the shared_run_indexing_with_notificationswrapper unpacks every indexer's return by length (search_source_connectors_routes.py:1499-1507:if len(result) == 3: a,b,c = result else: a,b = result), so a 4-tuple would raiseValueErrorfor every connector. The 2-/3-tuple return stays exactly as-is (wrapper untouched); the engine passes a freshstatsdict and reads it after the call (the wrapper passes none →None→ no-op).03awork-item 3 already reflects this (it exposescrawls_succeededvia task metadata only, with thisstatssink owned by Phase 6 — not via the positional return).
The engine bypasses _run_indexing_with_notifications (it acquires the connector lock itself at :1334 — going through the wrapper would self-deadlock on that same lock, and the wrapper's connector-Notification UI is the wrong surface for a pipeline run, which has its own PipelineRun record). It calls index_crawled_urls directly with no heartbeat callback. (Consequence: pipeline runs don't emit connector-indexing notifications and aren't touched by cleanup_stale_indexing_notifications — fine; run status lives on pipeline_runs.)
4. Fetch-only path + blob (app/pipelines/engine.py + app/pipelines/storage.py)
For save_to_kb=false:
async def crawl_urls_fetch_only(urls):
crawler = WebCrawlerConnector() # 03a: no firecrawl ctor arg
out = []
for url in urls:
outcome = await crawler.crawl_url(url) # 03a: CrawlOutcome(status, result, error, tier)
out.append({
"url": url,
"status": outcome.status.value, # CrawlOutcomeStatus: success | empty | failed
"content": (outcome.result or {}).get("content", ""),
"metadata": (outcome.result or {}).get("metadata", {}),
"error": outcome.error,
})
return out
03a return shape.
03ais committed to theCrawlOutcomedataclass (status/result/error/tier) — the tuple option was dropped precisely because this fetch-only path consumes.status/.result/.erroras attributes. So the unpack above is the firm contract; no adaptation needed.
# storage.py
def build_pipeline_run_result_key(*, workspace_id: int, run_id: int) -> str:
return f"pipeline_runs/{workspace_id}/{run_id}/result.json"
async def persist_run_blob(run, outcomes) -> str:
key = build_pipeline_run_result_key(workspace_id=run.pipeline.workspace_id, run_id=run.id)
data = json.dumps({"runs": outcomes}).encode()
await get_storage_backend().put(key, data, content_type="application/json")
return key # → run.result_blob_key
- One blob per run (single configured backend, like document files).
result_blob_key(Phase 5 column) stores the key; reads resolve viaget_storage_backend(). - Billing still applies here —
crawls_succeededis counted fromoutcomes, so non-KB runs cost exactly the same as KB runs (umbrella line 105). This is the whole reason the run engine, not the indexer, owns pipeline billing.
5. Scheduler tick (app/pipelines/scheduler.py + tasks.py)
A near-copy of the automations selector, retargeted at pipelines:
@celery_app.task(name="pipeline_schedule_select")
def pipeline_schedule_select() -> None:
return run_async_celery_task(_tick)
async def _tick():
async with get_celery_session_maker()() as session:
now = datetime.now(UTC)
await _self_heal_null_next(session, now) # enabled + cron + next_scheduled_at IS NULL → compute
claims = await _claim_due(session, now) # enabled + next_scheduled_at <= now, FOR UPDATE SKIP LOCKED, advance
for c in claims:
await _start_run(session, c) # create PipelineRun(trigger=scheduled, status=pending) + run_pipeline.apply_async
_claim_due:select Pipeline where enabled AND connector_id IS NOT NULL AND schedule_cron IS NOT NULL AND next_scheduled_at IS NOT NULL AND next_scheduled_at <= now ORDER BY next_scheduled_at LIMIT 200 FOR UPDATE SKIP LOCKED(uses Phase 5's partialix_pipelines_due). Theconnector_id IS NOT NULLfilter is defense-in-depth so a non-connector pipeline (the Phase-7 Uploads pipeline) can never be scheduled into perpetually-failing runs even if a cron is somehow set on it — the engine wouldfailevery such run (§1); this stops them at the selector. (Phase 7 also guards the API; this is the belt to that suspenders.) Advancenext_scheduled_at = compute_next_fire_at(schedule_cron, schedule_timezone, after=now); onInvalidCronError→enabled=False(self-disable, likeselector.py:90-96,131-137). Commit, then create the run + enqueue (mirror_start_onereload-after-commit,selector.py:153-183)._self_heal_null_next: backfillsnext_scheduled_atfor enabled cron pipelines missing it (fresh inserts, restored rows) — so Phase 5's create can leave it NULL and the tick fills it within ≤1 min.- Duplicate suppression: in
_start_run, skip creating a scheduled run if the pipeline already has a non-terminal run (status IN (pending, running)) — mirrors the connector checker's in-progress guard (schedule_checker_task.py:100-116). Without it, a pipeline whose run outlasts its cron interval piles upscheduledruns that immediatelyfailon the lock (noisy, though harmless).next_scheduled_atis still advanced (the fire isn't retried —catchup=False, like the automations selector). - Beat entry: add
"pipeline-schedule-select": {"task": "pipeline_schedule_select", "schedule": crontab(minute="*"), "options": {"expires": 50}}tocelery_app.py:268-324(default fast queue, likecheck_periodic_schedules). ("app.pipelines.tasks"is already inincludefrom Phase 5's stub, sopipeline_schedule_selectin the same module is auto-registered.) schedule_cronedits (Phase 5PUT): whenschedule_cron(or timezone) changes, nullnext_scheduled_atso the tick recomputes (smallPUT-handler addition — see Required 05 amendment). NULL cron ⇒ manual-only (tick ignores it).
6. De-dup guard (resolves Phase-5 deferred double-bill)
When a pipeline that wraps a connector is created or enabled (Phase 5 routes), set that connector's periodic_indexing_enabled = False (+ next_scheduled_at = None) so a single scheduler owns each connector (the pipeline is authoritative, per Phase 5's recommendation). Two touch points:
- Phase 6 owns the rule; implement it in the Phase-5
POST /pipelinesandPUT /pipelines/{id}handlers (whenconnector_idis set andenabled). This is a tiny handler addition (Phase 5 explicitly handed it here). - Belt-and-suspenders: the run engine already takes the connector lock during a run, so even if both scan, only one crawl proceeds at a time (the connector checker skips on a held lock,
schedule_checker_task.py:93).
7. Chat-agent run-history tool (main_agent/tools/pipeline_runs.py)
A read-only tool registered in the main-agent registry (umbrella default = tool):
def create_get_pipeline_runs_tool(*, workspace_id: int) -> BaseTool:
@tool
async def get_pipeline_runs(pipeline_id: int | None = None, limit: int = 20) -> str:
"""Recent pipeline run history for THIS workspace: name, status, trigger,
schedule, counts, started/finished. Read-only."""
async with shielded_async_session() as session: # knowledge_tree/middleware.py:199 pattern
q = (select(PipelineRun, Pipeline.name, Pipeline.schedule_cron)
.join(Pipeline, PipelineRun.pipeline_id == Pipeline.id)
.where(Pipeline.workspace_id == workspace_id))
if pipeline_id is not None:
q = q.where(Pipeline.id == pipeline_id)
rows = (await session.execute(
q.order_by(PipelineRun.created_at.desc()).limit(min(limit, 100)))).all()
return render_compact(rows) # name • status • trigger • finished_at • docs/crawls
return get_pipeline_runs
- Registration: add
"get_pipeline_runs": (_build_get_pipeline_runs_tool, ("workspace_id",))to_MAIN_AGENT_TOOL_FACTORIES(registry.py:85-102) — the dep key isworkspace_id, matching the post-rename deps dict; add the name tomain_agent/tools/index; add display metadata toshared/tools/catalog. - Workspace scope is structural — the tool only ever sees its build-time
workspace_id; no cross-workspace leakage (same guaranteeKnowledgeTreeMiddlewarerelies on). - Anonymous / no-workspace turns: exclude the tool when there's no
workspace_id(anonymous chat has no workspace/pipelines), the same wayKnowledgeTreeMiddlewareis CLOUD-only (knowledge_tree/middleware.py:130-131). The registry factory requiresworkspace_id, so just don't enable it in the anonymous tool set. - The tool reads
pipelines/pipeline_runsdirectly (no Zero dependency), so it works server-side immediately even though client Zero sync is dormant until the frontend lands (Phase 5 note). - Deferred: an always-on
<pipeline_activity>middleware injection (à laKnowledgeTreeMiddleware) — adds per-turn token cost; revisit if the agent under-uses the tool.
8. (Optional) blob read endpoint
Phase 5 stores result_blob_key but exposes no reader. Add GET /pipelines/{id}/runs/{run_id}/result → stream the blob via get_storage_backend().open_stream(run.result_blob_key) (404 if NULL), CONNECTORS_READ, workspace-scoped — mirroring file_storage/api.py:69-90. Lets API consumers + the chat tool fetch non-KB results. (Small; include if cheap, else defer to the public-API phase.)
Required 05 amendment (timezone + recompute)
Small, additive. Items 1–3 (the schedule_timezone column) are already folded into 05 (model/migration/schema/validator) — listed here for traceability. Item 4 is Phase-6 route logic added on top of Phase 5's handlers (Work items 5–6):
- (done in
05§2) Model:schedule_timezone = Column(String(64), nullable=False, default="UTC", server_default="UTC")onPipeline. - (done in
05§4) Migration:schedule_timezone VARCHAR(64) NOT NULL DEFAULT 'UTC'inCREATE TABLE pipelines. - (done in
05§5) Schema:schedule_timezone: str = "UTC"onPipelineBase+PipelineUpdate;(schedule_cron, schedule_timezone)validated together viavalidate_cron(cron, tz)(cron.py:15). - Routes (Phase 6 adds to
05§7 handlers): onPUTwhenschedule_cron/schedule_timezonechanges, setnext_scheduled_at = None(tick self-heals). OnPOST/PUTwithconnector_id+enabled, apply the §6 de-dup guard.
(If we'd rather not touch 05: hardcode "UTC" everywhere in Phase 6 and skip the column — but that loses operator-meaningful local schedules and diverges from automations. The column is the right call.)
Cross-plan coordination
03a— Phase 6 callsWebCrawlerConnector.crawl_url(url)directly in the fetch-only path and relies on03a'sCrawlOutcome.status(SUCCESS/EMPTY/FAILED, dataclass form — already locked in03a).03awork-item 3 is already aligned: it surfaces thecrawls_succeededcounter via task metadata only (not by wideningindex_crawled_urls's positional return — the shared_run_indexing_with_notificationswrapper unpacks by tuple length,:1499-1507). Phase 6 owns thefolder_id/urls/statsparams;03aowns the counter +CrawlOutcome.03c— add thebill: bool = Trueparameter to the in-indexer billing (so the pipeline path can suppress it).03c'sWebCrawlCreditServicestatics (billing_enabled(),successes_to_micros(n),check_credits,charge_credits) are reused verbatim by the run engine.03c's risk note already anticipated this: "Phase 5'sPipelineRuncan persistcharged_microsfor stronger idempotency" — Phase 6 is where that lands.05— the four amendments above;run_pipelinetask name +/runenqueue already exist (stub → real);charged_micros/crawls_*/result_blob_keycolumns already exist (pre-built in Phase 5).04a—is_pipeline_eligible(connector_type)is imported for the run-time re-check.
Work items
app/pipelines/package:engine.py(execute_pipeline_run+mark_running/succeeded/failhelpers +workspace_owner_id+crawl_urls_fetch_only),tasks.py(run_pipeline,pipeline_schedule_select),scheduler.py(_tick/_self_heal_null_next/_claim_due/_start_run),storage.py(blob key +persist_run_blob).- Pipeline lock helpers: add
acquire_pipeline_indexing_lock(id)/release_pipeline_indexing_lock(id)toutils/indexing_locks.py(keyindexing:pipeline_lock:{id}), mirroring the connector lock (:19-40). - Crawler changes:
index_crawled_urlsgainsfolder_id+urls+bill+ astatsout-param (populatesdocuments_indexed/crawls_attempted/crawls_succeeded). No return-shape change → no caller changes (the shared wrapper's 2-/3-tuple unpack stays valid). Coordinate with03a/03c(see "Cross-plan coordination"). - Celery wiring (
celery_app.py): ensure"app.pipelines.tasks"is ininclude(added in Phase 5 for the stub); routerun_pipeline → CONNECTORS_QUEUE; add thepipeline-schedule-selectBeat entry. - De-dup guard in Phase-5
POST/PUT /pipelines(disable wrapped connector's periodic indexing). 05amendment:schedule_timezonecolumn/migration/schema/validator +next_scheduled_atreset on cron edit.- Chat tool
get_pipeline_runs+ registry/index/catalog registration. - (Optional)
GET …/runs/{run_id}/resultblob reader. - Tests (below).
Tests
- Manual run, KB-save: WebURL pipeline
save_to_kb=true+destination_folder_id→ run goespending→running→succeeded,Documents created in that folder,documents_indexed/crawls_*set,finished_atset. - Manual run, non-KB:
save_to_kb=false→ noDocuments,result_blob_keyset + blob readable,crawls_*set. Billing still chargescrawls_succeeded(parity with KB run). - Billing: enabled + sufficient → owner debited
crawls_succeeded * 1000,charged_microsstamped, oneweb_crawlTokenUsage; insufficient → run fails pre-crawl (FAILED), no debit; disabled (self-hosted) → no charge, noweb_crawlrow,charged_microsstays NULL. - Idempotency / lifecycle: re-enqueuing a
RUNNINGor terminal run → immediate no-op (status != PENDINGgate) → no second charge (the primary guard;mark_runningcommits before any charge); engine never raises out offail()paths (bad type / NULL connector / no URLs / ineligible / lock contention) — all land the run inFAILEDand release locks. - Eligibility re-check: pipeline over a connector whose type is now
MIGRATING→ run fails cleanly (not crash). - Scheduler: a due cron pipeline → tick creates a
scheduledrun + advancesnext_scheduled_atto the next cron match (inschedule_timezone); NULLnext_scheduled_atself-heals; invalid cron self-disables (enabled=False); manual-only (NULL cron) never fires. - De-dup: creating/enabling a pipeline over a connector flips that connector's
periodic_indexing_enabled=False; concurrent connector-checker tick skips on the held lock. - Concurrency: a second run while one is in-flight → fails with "already in progress" (locks held).
- Chat tool:
get_pipeline_runsreturns this workspace's runs only; another workspace's runs never appear; respectslimit. - Counts contract (regression-critical):
index_crawled_urlskeeps its existing 2-/3-tuple return (the shared_run_indexing_with_notificationslength-unpack still works for all connectors); when astatsdict is passed it's populated withdocuments_indexed/crawls_attempted/crawls_succeeded;bill=Falsesuppresses in-indexer billing; connector path (bill=True, nostats) behaves byte-for-byte as before. Add a wrapper test asserting webcrawler indexing through_run_indexing_with_notificationsstill unpacks cleanly.
Risks / trade-offs
- Billing split across two call sites (indexer for connector path, run engine for pipeline path), unified only by
03c's shared statics. Accepted: it's what makescharged_microsrun-idempotency clean and keeps both KB branches billed identically. Thebillflag is the single seam. - Stuck
RUNNINGruns. A crash aftermark_runningbut before terminal leavesstatus=RUNNINGforever (thestatus != PENDINGgate then no-ops any redelivery — same property as automations). No double-charge (gate +charged_micros), but the run never completes and its locks rely on Redis TTL to free. A stale-run sweeper (à lacleanup_stale_indexing_notifications,celery_app.py:279-285) is deferred; MVP exposure is a rare worker crash. - Two crawl code paths (
index_crawled_urlsfor KB vscrawl_urls_fetch_onlyfor non-KB). Minor duplication of the crawl loop; deliberate, since forcing theDocument-centric 2-phase indexer into a "don't persist" mode is messier. Both shareWebCrawlerConnector.crawl_url+ the same billing math. - WebURL-only executor. A pipeline over GDrive/OneDrive/Dropbox is creatable (Phase 5 lets
AVAILABLE DATA_SOURCE) but fails at run time ("executor not implemented"). Acceptable for MVP (those keep their connector-periodic path); generalize the executor in Phase 7+. - De-dup guard is one-way. Creating/enabling a pipeline disables the wrapped connector's
periodic_indexing_enabled, but disabling or deleting that pipeline does NOT restore it — so a connector can end up indexed by neither path (data goes stale) until the user re-enables connector periodic indexing manually. MVP-acceptable (operator action); a symmetric restore-on-disable is a small follow-up. Flag in the (deferred) UI copy. - Blob backend assumption.
result_blob_keystores only the key (no backend name, unlikedocument_files.storage_backend). Fine for the single configured backend; if multi-backend ever lands, add a backend column topipeline_runs(additive). - Non-KB blob built in memory.
crawl_urls_fetch_onlyaccumulates every URL's extracted content into one JSON blob before upload — a large URL list (or huge pages) inflates worker memory. Acceptable for MVP URL counts; if it bites, stream to the backend or cap per-run URLs (aconfigknob). Also: non-KB runs have no dedup (every run re-crawls + re-bills all URLs) — that's inherent to "don't persist", and the user opted out of KB. - Lock lifetime vs run length. The Redis locks use
CONNECTOR_INDEXING_LOCK_TTL_SECONDS; a crawl run longer than the TTL could let a second run start. Pre-existing property of the connector lock (the connectors queue caps at 8h); revisit the TTL if pipeline runs routinely exceed it. - Two minute-level Beat scans (connector checker + pipeline tick) now run every minute. Both are indexed (
ix_pipelines_duepartial index; connector scan on its own predicate) + batch-capped (200), so cost is negligible.
Out of scope (hand-offs)
- Uploads pipeline execution (
connector_id=NULL,trigger=upload, alwayssave_to_kb) → Phase 7 (07-upload-pipeline-kb.md). This engine explicitly fails NULL-connector runs. - Non-WebURL executors (GDrive/OneDrive/Dropbox pipelines) → Phase 7+.
- Always-on chat context middleware (
<pipeline_activity>injection) → deferred (tool-only for MVP). - Document→run provenance (
documents.pipeline_run_id) → deferred (touchesdocuments). - Public pay-as-you-go API over pipelines + public MCP KB server → post-MVP (umbrella "Deferred").
- Frontend Pipelines UI (run-history, manual run, schedule editor) → frontend umbrella.