跳转至

本页暂时只有英文版

Tasks

Task Data Persistence

  • Plan (task.plan): Saved by design agent, rendered as markdown in frontend
  • Result (task.result): the deliverable shown to the user. A derived view — see Task outcome — written only by apply_outcome, never assigned directly
  • Todos (task.todos): JSON-serialized TodoWrite state, persisted on each update
  • Messages (task_messages table): Full agent chat history, reloaded on task select
  • Steps (task_steps table): Only from simple executor; agent tasks use todos/messages
  • On retry: all steps, messages, logs, todos, plan, plan_history and every outcome input are cleared (clear_run_state(task, reset_submissions=True)); per-task workspace is wiped (prepare_task_workspace(clean=True)) for a fresh start
  • Plan/result language auto-detected from task title (Chinese → 中文, else English)

Task outcome

app/task_outcome.py owns one question — what did this run produce? Terminal status is a pure function of the answer, and nothing else may decide it.

It used to be derived instead, by if task.error: in a reader that never looked at task.result. Four writers could set that pair at four different times, so each guard against a contradictory ending reconciled one writer against one other. A scheduled ad-audit run shipped FAILED while holding a finished report, showing the agent's chat narration as its result.

The four columns

Column Written by Role
accepted_result result endpoint, on accept a submission that cleared every gate
submitted_result result endpoint, on every submit the last thing the agent submitted, refused or not
review_gaps result endpoint / apply_outcome JSON string[] of unmet items from the last verdict
transcript_tail _save_result (end of turn) streamed assistant prose
result apply_outcome only the derived view the user sees

submission_count is observability: a high count with no accepted_result is the signature of a gate the agent cannot satisfy.

Precedence

resolve_outcome(task) picks the best available deliverable:

  1. accepted_result → DELIVERED
  2. submitted_result → INCOMPLETE, with review_gaps as caveats
  3. transcript_tail → DELIVERED (a real fallback: most tasks never call set_task_result, and for a lookup the chat output is the answer)
  4. nothing → FAILED

An infra-detected error (browser launch, stall reaper, stopped-by-user) outranks all of it. An agent-reported error does not — an agent that produced output and then explained its caveats has not failed, so the error becomes a caveat on the result. apply_outcome then persists the consumed caveats into review_gaps and clears the error pair, which is what makes re-resolution idempotent (finalizers run more than once).

DELIVERED and INCOMPLETE both map to completed (TERMINAL_STATUS): a partial deliverable with an honest gap list is something the user can act on, not a failure.

Refusal retains the submission

Reviewer gates refuse by raising, and the raise happened before task.result was assigned — so a refusal destroyed the submission and N refusals left the run holding nothing. Submission and verdict are now separate facts: retain_submission commits what was submitted before any gate runs, and refuse annotates it with the gaps (falling back to the gate's prose, capped, when a gate has no structured item list). See app/routers/task_submission.py.

The incomplete exit

vibe_seller_set_task_result(result, incomplete=[...]) lets an agent say "here is what I got, here is what I could not". The items ride along as caveats. It bypasses no gate — the submission is reviewed exactly as it would be otherwise. It exists because set_task_error means unrecoverable failure and was being used to explain caveats over real output, simply because it was the only door out.

Terminal-state writer

app/task_finalize.py (finalize_terminal_state) is the single writer of a terminal status for a RUNNING/DESIGNING task. It resolves the outcome first, so every branch below it — wait-condition, incomplete-todos, Q&A parking — reads the same task.result the user will see.

Task Status Lifecycle

Auto mode (default): pending → running → completed / failed / waiting

Plan mode (plan_mode=true): pending → designing → planned → (user confirms via execute-plan) → running → completed / failed / waiting.

Plan-only tasks (is_plan_only=true): a special plan-mode variant that authors a reusable plan for a Schedule and terminates at completed without ever running. Flow: pending → designing → planned → (user approves via execute-plan) → completed. The approval hook writes the plan to Schedule.plan, bumps Schedule.plan_version, sets plan_status='ready', and responds deny to the ExitPlanMode control request so the agent halts instead of entering bypass mode. See Plan-at-creation lifecycle.

Scheduled fires (children of a plan-mode Schedule): auto-approve the plan at execution since the human already reviewed it at creation time. They re-inject per-store context (L3 catalog, bookmarks, emails) and reuse Schedule.plan as-is.

Note: queued is used for store-task launches from pending/waiting/failed states. submit() only transitions to queued from those three — planned tasks enqueued for execution stay planned in the DB so _approve_plan_request can transition them directly to running. failed is in that set because it is in STARTABLE: the retry button (POST /tasks/{id}/start) accepts a failed task, so leaving the row failed here made retry a silent no-op — the task was enqueued and then dropped by _on_start, which only spawns from planned/queued/running. Keep submit()'s set and STARTABLE in agreement. The execute_plan endpoint explicitly sets planned → queued before enqueueing to prevent duplicate submissions. No-store tasks launch directly via _auto_run_task().

queued/pending means "waiting for the agent-concurrency slot" — a task in these states has NOT started and produces no output (input bar disabled via TASK_UI, and the stall reaper — which is RUNNING-only — ignores it). The transition out is owned by on_start, which the manager awaits after acquiring the semaphore and before session.start() (claude_backend_manager.run), so a live session's status is always RUNNING/DESIGNING by the time it streams. Invariant enforced two ways so a missed/late/clobbered task_update(running) can't strand the badge on queued (the pending→queued→running SSE race — see retryTask.ts, sseCreateTaskRace): (a) backend — _emit_message reconciles a still-QUEUED/PENDING task to RUNNING/DESIGNING on its first streamed message (_status_reconciled one-shot; a backstop, inert in the normal path); (b) frontend — useSSE promotes a queued/pending task to running/designing on any streamed task_message, never touching terminal/active states. Net: a task emitting session output is never displayed as queued.

  • waiting: long-running tasks paused for external events (e.g., marketplace case response, listing approval). Triggered these ways: (1) agent signals via wait-condition block in result text, (2) agent exits with incomplete TodoWrite items (_has_incomplete_todos → mark_waiting_for_input, check_strategy: manual), (3) agent exits while an AskUserQuestion is still outstanding (park_waiting_for_pending_question in app/task_runner.py) — the question payload is persisted into wait_condition.pending_question so the operator can answer later via the UI, or (4) interactive-Q&A prose follow-up: the agent called AskUserQuestion, got answered, then ended its final segment with PROSE ONLY — no follow-up tool action and no explicit vibe_seller_set_task_result (itself a tool_use). That shape is "asking again / awaiting more input", not "done" — but the streaming-prose fallback already populated task.result, so the empty-result waiting-parks above (all gated on not task.result) miss it and the task would silently COMPLETE mid-conversation. _finalize_terminal_state detects it structurally via two session signals — _asked_user_question (a question was asked this session) AND NOT _tool_use_since_answer (no real, non-reflection tool ran after the last answer) — and calls park_waiting_for_text_only_response. It is deliberately NOT a content heuristic: a single-turn task that never asked anything (_asked_user_question=False) always completes, and FakeAgent sessions lack these attrs (getattr default False) so workflow finalizer tests are unaffected. System periodically checks email conditions or user manually wakes.

WAITING with a pending question

When the agent subprocess exits while holding an unanswered AskUserQuestion, AgentSession snapshots _pending_questions into _last_pending_questions (both in stop() and the stream-finally path in app/ai/claude_backend_stream.py), and auto_run_task checks that snapshot before the FAILED transition. If a snapshot exists, the task is parked in WAITING with a wait_condition shaped like:

{
  "reason": "Agent asked a question; awaiting operator input",
  "check_strategy": "manual",
  "waiting_since": "...",
  "max_wait_days": 7,
  "pending_question": {
    "request_id": "req-xxx",
    "questions": [{"question": "...", "options": [...]}]
  }
}

The operator answers via POST /api/tasks/{id}/questions/answer exactly as for a live session. The endpoint (app/routers/tasks_conversation.py) has two branches: if the agent is still running it forwards over IPC; if the session is dead and the task is WAITING with a matching request_id, it persists the answers into wait_condition.answers, flips WAITING → QUEUED, and re-queues. Store tasks go through task_queue_scheduler; no-store tasks are launched directly via auto_run_task.

On re-dispatch, auto_run_task calls maybe_inject_pending_answers() which composes a user-turn prefix from wait_condition.answers + the stored questions, clears wait_condition, and passes resume=True so the spawned session runs claude --resume <task.session_id> and picks up the transcript. The resumed agent sees the operator's answers as the next user message and continues. - Transitions: running → waiting (agent signals wait OR incomplete todos), waiting → queued (woken) / failed (timeout), failed → queued (retry) - State machine defined in app/task_states.py (backend) and frontend/src/taskStates.ts (frontend UI config) - Backend: TaskStatus enum, TRANSITIONS table, named groups (STOPPABLE, ACTIVE, WAKEABLE, etc.) - Frontend: TASK_UI config table maps status → which buttons/tabs/panels are visible - Contract test ensures both sides agree on status values - Waiting checker: APScheduler interval job (15 min) in app/scheduler/waiting.py checks email conditions, times out stale waits - Wake endpoint: POST /api/tasks/:id/wake with optional {message} body resumes waiting tasks

Parent / Sub-Task Hierarchy

Orchestrator (non-store) tasks can create per-store sub-tasks via parent_task_id. This enables cross-store workflows where a single parent task fans out work to individual stores. The parent also has a store-less web browser (see docs/browser.md) for neutral public web work (search, tracking/logistics) it can do directly; anything touching a store's seller center still goes to a sub-task.

  • Parent task: A non-store task (store_id=None) that coordinates the overall workflow
  • Sub-tasks: Per-store tasks linked via parent_task_id. Each sub-task must have a store_id
  • Default fan-out: One sub-task per store. Country switching happens within a single sub-task's browser session (not via separate sub-tasks per country)
  • Query: GET /api/tasks?parent_task_id= returns all sub-tasks for a given parent
  • Wait-for-children: When the parent agent finishes creating sub-tasks, the parent transitions to WAITING (with wait_condition: {"strategy": "children"}) instead of completing immediately. As each sub-task reaches a terminal state (completed/failed), _check_parent_completion() checks all siblings — when all are terminal, the parent auto-completes with an aggregated summary of child results

Execution Pipeline

Tasks execute on creation via _auto_run_task(). The mode depends on plan_mode:

Auto Mode (default, plan_mode=false)

POST /api/tasks → status: "pending"
  → _schedule_or_run(task_id, store): store tasks → TaskQueueScheduler.submit() → "queued"
    No-store tasks (store_id=None) → asyncio.create_task(_auto_run_task()) directly
    Fanout L2 tasks are the exception: submitted to queue with store_id=None, bypass browser/session
  → Queue dispatch infers launcher: plan exists → _execute_planned_task, woken → _execute_woken_task, else → _auto_run_task
  → _auto_run_task():
    1. browser_manager.write_browser_config_for_store() (store tasks) /
       write_web_browser_config() (no-store tasks → bin/_web wrapper)
    2. knowledge_sync.fetch()
    3. agent.run(mode="auto") — bypassPermissions from start:
       a. Agent starts with --permission-mode bypassPermissions
       b. SDK hooks: AskUserQuestion → forwarded to backend, all others auto-approved + circuit breaker
       c. No ExitPlanMode dance — agent has full tool access immediately
       d. status: "running" (set before agent starts)
    4. Done → "completed" / "failed" / "waiting"
    5. Guard: agent exits without result → "failed" (agent_empty_result)

Plan Mode (opt-in, plan_mode=true)

POST /api/tasks → status: "pending"
  → _auto_run_task():
    1-2. Same setup steps
    3. agent.run(mode="plan_then_execute") — native plan mode:
       a. Agent starts with --permission-mode plan (read-only)
       b. Agent plans → calls ExitPlanMode → status: "designing"
       c. Backend intercepts via hooks, captures plan → status: "planned"
       d. Wait for user approve/reject
       e. Approve sends SetMode → bypassPermissions → status: "running"
       f. Agent continues executing in same session with full access
    4. Done → "completed" / "failed" / "waiting"

System Prompt

Both modes use DESIGN_SYSTEM_PROMPT (knowledge recall, critical thinking, approach selection, subagent rules). Auto mode strips the Phase 5 — Plan Output and ExitPlanMode Rules sections via <!-- PLAN_MODE_ONLY --> delimiters. Post-task knowledge capture is delivered via the Stop hook (not embedded in the system prompt).

Each agent runs in a per-task isolated directory (~/.vibe-seller/tasks/{task_id}/) with symlinked shared resources — see workspace.md.

No manual buttons needed. Users see: progress indicator + Stop button + Retry on failure or completion (RETRIABLE = {FAILED, PENDING, COMPLETED}). Toggle "Review Plan" in the task detail footer to switch between modes.

Stop lives only in the task header — never in the chat composer. The composer's send slot used to render Send when the box had text and a red Stop when it was empty, but sending is exactly what empties it: the click that sent a message left a destructive button in the same pixels (measured: Send x1110–1176, Stop x1121–1176, identical y/height), so a double-click killed the run with no confirmation. General rule: a destructive control must never occupy the position a safe control just vacated.

Retry on a failed store task goes failed → queued (via submit()) → dispatched by _on_start, which spawns only from planned/queued/running. Those two sets must stay in agreement — when submit() left the row failed, the retry returned 200 {"status":"queued"} and was then silently dropped at spawn (Exec on_start: task … in status failed — aborting spawn).

Follow-up on Completed/Failed Tasks

Users can send follow-up messages to tasks that have already completed or failed — to continue incomplete work, ask questions about results, or give additional instructions. The chat input is enabled for both terminal states.

When a message is sent to a completed or failed task:

  1. Backend clears stale error/error_category (if failed)
  2. Auto mode (plan_mode=false): clears stale result/todos/wait_condition, transitions to running, starts agent with mode='auto'
  3. Plan mode (plan_mode=true): transitions to designing, starts agent with mode='plan_then_execute' and resume=True
  4. If agent fails to start, status reverts to the previous terminal state with error fields restored

This differs from the waiting → queued wake path: waiting tasks are explicitly paused by the agent for external events, while follow-ups are user-initiated continuation of finished work.

Fresh-session context-rot guard

resume=True is a request, not a guarantee. When the task transcript exceeds VIBE_FRESH_SESSION_MSG_LIMIT (default 400 task_messages rows), the follow-up handler in tasks_conversation.py treats the session as non-resumable and starts a fresh CLI session seeded with the compacted context the non-resume branch already builds (app/ai/compaction.py: full raw history → a JSON file under ~/.vibe-seller/task_history/, last 5 messages inline, plan inline, plus an instruction to read the file). The task workspace — files on disk, markers, reports — is unchanged.

Why fresh instead of resuming-and-summarizing: the observed failure mode on long transcripts is context rot, not size — superseded conclusions and stale artifact paths persist as "facts" and the agent re-verifies dead state. A model-written summary distills that narrative; a fresh session re-grounds in current reality (disk state, live pages) and pulls raw history on demand. Division of labor: Claude Code's native auto-compaction handles intra-turn context pressure inside a session; this guard handles inter-turn rot between sessions. The limit is a row-count heuristic — tune via the env var.

Plan Feedback

Users send feedback via the unified chat input at the bottom of the conversation view — no separate design buttons or plan-editing UI. When a message is sent while the task is in planned status:

  1. Backend calls reject_plan() on the running session — agent stays in plan mode, incorporates the feedback, and re-plans (PLANNED → DESIGNING → PLANNED cycle)
  2. If the session has died, a fresh plan_then_execute session starts with the feedback injected as the opening prompt

AI Agent Backend

The primary execution path routes through the AIAgentBackend abstraction in app/ai/:

  • base.py: AIAgentBackend ABC — run(), stop(), submit_answer(), send_message(), is_running()
  • claude_backend.py: Claude Code CLI subprocess implementation
  • --add-dir ~/.vibe-seller — loads workspace skills + knowledge
  • --output-format stream-json — structured streaming output
  • --permission-mode plan|bypass — native plan mode or full access
  • --permission-prompt-tool stdio — hook/control protocol for plan approval
  • approve_plan() / reject_plan() — plan approval within a running session
  • Post-task reflection (execution) — auto-updates knowledge/skills via REFLECTION_PROMPT
  • Control protocol: ExitPlanMode triggers hook_callback → respond permissionDecision: 'ask' → CLI sends can_use_tool → handler saves plan, sends {behavior: 'allow', updatedPermissions: [SetMode]} or {behavior: 'deny', message: '...'}
  • Turn lifecycle (process-per-turn model): a turn is one CLI process, and the turn ends when the process exits. The CLI never exits on its own while stdin is open (spike-verified vs claude 2.1.215), so closing stdin is the turn terminator — owned by the quiescence watchdog (app/ai/claude_backend_turns.py): stdin closes only when an accepted result exists for the current turn, the review/exec gates pass, no tracked async work is pending, no AskUserQuestion is parked, and the stream has been idle for the linger window (VIBE_TURN_LINGER_S when async work was launched / VIBE_TURN_LINGER_QUIET_S otherwise; 0 = close at the result event, the current default until the flip). Async work = async subagents (Agent tool) AND background shell commands (Bash/PowerShell auto-backgrounded or run_in_background); both register in _async_agents and clear on their <task-notification>, so a turn does not end while a find/export it launched is still running. Backstops: VIBE_TURN_HARD_IDLE_S closes on total stream silence regardless of holds, and a post-close kill escalation _force_kill()s a process that stays alive past the grace (bounds the GLM stall-after-result wedge the old design left unbounded). A runaway background command (never completes → force-closed while running → killed with no completion record) would poison the next --resume with a reconciliation notification, so a follow-up starts a fresh session when the prior one has an orphaned background task (session_has_orphaned_bg_task). Every ACCEPTED execute-phase result event is a turn boundary: it emits its own role='result' card (a process hosts many turns — redrives, task-notification continuations, injected follow-ups) and the LAST non-empty one is the deliverable (_result_text, last-wins; explicit set_task_result still takes precedence). _last_result_is_error is per-result (assigned, not sticky) and folded into _is_error_result at exit so a recovered turn ships COMPLETED while forced errors (circuit breaker, rc!=0) stay FAILED. During planning stdin always stays open (multi-turn feedback); the plan-skip close (result with no plan saved) is immediate to avoid a deadlock.
  • Follow-up delivery: send_user_message returns a delivery bool and OPENS A NEW TURN (_turn_result_seen reset); the conversation router falls through to the resume/fresh-session spawn when the write misses the live pipe, so a follow-up racing the turn terminator is re-routed instead of silently dropped.
  • _emit_lock: serializes message persistence per session to prevent seq/timestamp races
  • Session-end signalling: each session exposes two asyncio.Events — done (idempotent end-of-session signal, typically set from _stream_output's finally but also from stop()'s defensive early-return path; asyncio.Event.set() is idempotent, so extra calls are harmless and waiters observe only the first) and plan_saved_event (set when _save_design_plan commits a plan, cleared on approve/reject so reuse doesn't short-circuit). _wait_for_session_end in task_runner_auto.py blocks on these instead of polling is_running(). No time-based backstop: _stream_output's finally is a Python-level guarantee and AgentSession.stop() already caps subprocess-exit latency via signal escalation; _recover_from_db in app/scheduler/task_queue.py is the absolute restart-time backstop for any task stuck in RUNNING/DESIGNING. Shared by auto_run_task, execute_planned_task, execute_woken_task, and finalize_followup_session
  • Resume-failure retry — owned by the orchestrator, not the manager. Every lifecycle path (auto_run_task, finalize_followup_session, execute_planned_task's fresh-session branch, execute_woken_task) calls one helper wait_for_session_with_retry(task_id, session) in app/task_session_lifecycle.py (extracted there so both task_runner_auto.py and task_runner_exec.py import from one place), which does _wait_for_session_end → _maybe_retry_without_resume → _wait_for_session_end in a single coroutine. _maybe_retry_without_resume checks for the resume-failure pattern (session._proc.returncode != 0 AND session.resume_session_id set AND no _result_text); if matched, it clears stale task.session_id / task.result / task.error (so the post-retry finalizer sees only this attempt's outcome — without this, prior task.result from earlier rounds misclassifies the retry as success), then calls agent_manager.retry_without_resume(task_id) which creates a fresh AgentSession inheriting all args (prompt, system_prompt_extra, mode, profile_id, message_history, store_slug, task_dir, auto_approve_plan, skip_reflection, no_store) from the prior session. Single owner per task — eliminates the prior race where the manager's hidden _release_on_done retry path could either miss a finalize (orphaned retry) or let the orchestrator finalize on prior-run residue while a retry was still running. The detector is heuristic (any rc!=0 startup with resume + no result triggers retry, not just stderr-confirmed No conversation found); a future refinement is to surface a typed session.resume_rejected flag from claude_backend_stream instead of inferring

Agent Context Injection

All agent prompts go through one function: _build_system_extra() in app/task_runner.py. Callers pass a TaskHeader enum value and get back a PromptBundle(prompt, system_extra, mode).

TaskHeader Caller prompt mode
DESIGN _auto_run_task (plan), design_task "Design an execution plan for this task: {title}" plan_then_execute
AUTO _auto_run_task (auto) "{title}\n\nDetails: {desc}" auto
EXECUTE _execute_planned_task task description execute or auto
WOKEN _execute_woken_task task description execute or auto
CHAT send_task_message task description varies

Assembly order (fixed for all task types): 1. Base prompt (design_system.md with {workspace_guidance} filled) 2. Language hint → 3. Waiting instruction → 4. Store/all-stores context → 5. TickTick → 6. System context → 7. Reflection (skipped for catalog sync) → 8. Header extra (plan text) → 9. Caller extra (conversation history)

start_agent() (ad-hoc) intentionally skips full context — it's for raw interaction.

Task dispatch: All task launch paths (create, retry, continue, execute-plan, scheduled) route through TaskQueueScheduler. Store tasks are gated by platform/country compatibility (RUN, RUN_IN_NEW_TAB, QUEUE). No-store tasks (store_id=None) always dispatch immediately — they bypass the per-store browser config, session tracking, and store CDP proxy. They still get the store-less web browser wrapper (bin/_web), which lazy-starts its own Chrome + CDP proxy on first use via POST /api/browser/web/start (so a no-store task that never browses pays nothing). Per-store tasks can run concurrently when sharing the same platform/country, with CDP-level isolation provided by CDPMuxProxy.

Turn Lifecycle

See docs/backend.md § Turn Lifecycle for the full model and configuration. Task-level summary:

  • What starts a turn: task creation / plan approval (first turn of the process), a gate redrive, a <task-notification> continuation (an async subagent OR background shell command finished and the main agent resumes), or a follow-up message injected into the live process.
  • What ends a turn: an accepted result event (gates pass, no async work — subagents or background shells — pending) emits the turn's role='result' card. What ends the PROCESS — and only then the task's RUNNING state — is the quiescence watchdog closing stdin after the linger window, the hard idle bound, stop(), or the plan-skip close.
  • Task status vs turn state: the task stays RUNNING until the process exits (done → _finalize_terminal_state); a result card can therefore be visible while the status is still RUNNING — that is the linger window, and mid-window follow-ups are injected into the live process instead of spawning a --resume session.
  • Router fallback: if the follow-up write misses the live pipe (raced the terminator), send_message returns False and the conversation router falls through to the resume/fresh-session spawn (the user message is already persisted; persist_prompt=False).

Event Flow During Execution

Auto mode:

  ├── emit("task_update", status="running")  — agent starts with full access
  ├── emit("task_message", role/content)
  ├── emit("task_todos", todos)
  └── emit("task_update", status="completed")

Plan mode (plan_mode=true):

  ├── emit("task_update", status="designing")  — agent enters plan mode
  ├── agent plans (read-only tools)
  ├── agent calls ExitPlanMode
  ├── emit("task_update", status="planned", plan=...)  — plan captured via SSE
  │   └── wait for approve_plan/reject_plan
  ├── approve → SetMode(bypassPermissions)
  ├── emit("task_update", status="running")  — agent executes with full access
  ├── emit("task_message", role/content)
  ├── emit("task_todos", todos)
  └── emit("task_update", status="completed")

Scheduled Tasks

Recurring tasks use the Schedule model + APScheduler with MemoryJobStore. The Schedule DB table is the sole source of truth — APScheduler jobs are rebuilt on startup via rebuild_schedule_jobs().

  • Lifecycle (plan-mode schedules): On POST /api/schedules with plan_mode=true, the handler spawns an is_plan_only Task and sets Schedule.plan_status='planning'. The user reviews + approves the plan; the hook commits it to Schedule.plan and flips plan_status='ready'. Every subsequent cron fire (or manual /trigger) copies Schedule.plan into a new child Task (status planned) so the agent skips the design phase. Editing the schedule's prompt invalidates the plan to stale and aborts any in-flight planner; the user calls /replan to author a new plan. The fire-gate in scheduler/cron.py + fanout.py refuses to fire plan-mode schedules unless plan_status='ready' (is_system and plan_mode=false schedules are exempt). Full state machine in app/plan_states.py and docs/subsystems.md.
  • Lifecycle (non-plan-mode schedules): plan_status stays none, fire-gate passes through, child task runs in auto mode from pending.
  • API: /api/schedules CRUD with pause/resume/trigger/replan endpoints. PUT requires plan_version as an If-Match optimistic-lock token (412 on stale); plan_mode is immutable after creation (400 on attempted change).
  • Frontend: TasksView sub-tabs (One-time / Scheduled), schedule detail panel with plan-status badge, Re-plan button, and stale/failed banner surfacing plan_error.
  • Cron builder: build_cron_kwargs() converts schedule_type + time + day → APScheduler trigger kwargs
  • Stuck-planning reaper: app/scheduler/plan_reaper.py runs every 5 min, fails schedules stuck in plan_status='planning' whose planner task has been idle >30 min. Skips tasks in waiting (legitimate user-input wait — handled by waiting.py with its 30-day timeout).
  • SSE: schedule_triggered, fanout_triggered, schedule_skipped, schedule_waiting_cancelled, schedule_plan_ready, schedule_plan_timeout — see docs/events.md

Cross-run state (watermark / cursor)

Each run of a schedule is a fresh task — sibling runs share only schedule_id, not prompt history. To let cursor-style workflows ("read emails since last run", "process orders newer than last id") resume cleanly, scheduled tasks get two MCP tools backed by the schedule_state table:

  • vibe_seller_get_schedule_state(key) — read the value a prior run persisted under key; returns null on the first run.
  • vibe_seller_set_schedule_state(key, value) — upsert the cursor for the next run.

The agent never sees or passes schedule_id. The server resolves scope from the calling task (task.schedule_id) and returns 400 on non-scheduled tasks. Routes: GET/PUT /api/tasks/{task_id}/schedule-state/{key}. The PUT uses SQLite INSERT ON CONFLICT DO UPDATE so concurrent manual triggers don't race the PK insert.

Typed keys. Most keys are opaque strings, but a few are typed to protect agents from their own SQL-formatting mistakes. Today the only typed key is email_watermark, which must be a unix epoch seconds integer as a string (regex ^[0-9]{1,15}$) — ISO timestamps are rejected. This avoids a real failure mode: agents wrote ISO watermarks like '2026-04-17T13:50:57.009101+00:00', then truncated them to '2026-04-17T13:50:57' when pasting into a WHERE date > ... clause; under SQLite's lex comparison the original email date still counted as "greater than" the truncated watermark and re-appeared on the next run. Agents using email_watermark are told to query with WHERE CAST(strftime('%s', date) AS INTEGER) > <value>. The typed-value map lives in _TYPED_VALUE_PATTERNS in app/routers/tasks.py.

Two prompt injections glue this to the LLM — both gated on task.schedule_id and skipped for catalog-sync runs:

When File Builder Purpose
Pre-task app/prompts/scheduled_pretask.md _build_system_extra() in app/task_runner.py Tells the agent it's scheduled and to call get_schedule_state early before doing work.
Post-task app/prompts/scheduled_watermark.md Stop-hook stop_reflection callback in app/ai/claude_backend.py Appended to the reflection prompt; nudges the agent to set_schedule_state with the highest cursor it fully processed.

Independent from (and complementary to) the coarse Previous run completed at … line in _build_system_context — that's an automatic default, the watermark tools are precise and agent-controlled.

Task Queue Scheduler

Concurrent per-store execution with session-aware scheduling in app/scheduler/task_queue.py. Tasks run concurrently by default; only queued when they share the same platform but target different countries (Ziniao country-switch constraint).

Situation Decision
No running tasks for store RUN — start browser, dispatch
Session running, same platform+country RUN_IN_NEW_TAB — parallel in existing session
Different platform/country QUEUE — wait for current session to finish
  • Concurrent by default: tasks only queue when same platform + different country (Ziniao country-switch); once blocked, subsequent same-platform tasks for that store wait
  • Background _tick() loop checks queues every time a task completes or a new task is submitted
  • Thread-safe via asyncio.Lock
  • Recovers queued tasks on restart by scanning DB for pending/queued tasks

Task deletion + auto-cleanup

Two paths share one helper, app/task_delete.py:

  • Manual: DELETE /api/tasks/{id} (Delete button in the task detail header). Cascade-deletes the entire subtree — children, grandchildren, dependent rows in task_steps / task_messages / task_attachments / task_logs / screenshots, and the on-disk workspace at ~/.vibe-seller/tasks/{id}/. Best-effort stops any agent first. Returns 409 if any task in the subtree is designing / running (caller must Stop first). The frontend confirm warns when sub-tasks exist (tasks.deleteConfirmCascade). The Delete button is hidden for is_plan_only tasks (the frozen plan author of a Schedule — its plan lives on Schedule.plan and would orphan the schedule's history).
  • Automatic: APScheduler cron job task_cleanup registered in app/scheduler/cron.py, fires daily at 03:30 server-local. Reads AppSettings.task_retention_days (default 30; 0 disables). Deletes tasks where:
  • status is completed or failed,
  • updated_at is older than the retention window,
  • is_plan_only is false, and
  • the task is a leaf (no other task points at it via parent_task_id). Parent rows of fanout / scheduled runs stay around so the user can still see the run summary; only leaves get reaped. The job is hidden — it never inserts a Schedule row, so it doesn't appear in the Schedules UI.

The retention setting is configured in Settings → AI Agent → Auto-delete old tasks. UI input is clamped to [0, 3650]; PUT /api/settings enforces the same range (out-of-range values are clamped, non-integers are silently dropped).

Tests: tests/unit/test_routers/test_tasks_delete.py covers the endpoint + cascade behavior; tests/unit/test_task_cleanup.py covers the scheduler job (retention, leaf-only, is_plan_only skip, workspace dir removal); tests/workflow/test_wf_task_retention.py covers the settings CRUD.

Frontend Task Detail UI

The task detail panel uses a conversation-first layout rendered by ConversationStream:

  • PlanCard: Renders the captured execution plan as markdown (react-markdown + @tailwindcss/typography) at the top of the stream
  • ExecutionSeparator: Visual divider marking the boundary between the planning phase and execution messages
  • MessageBubble: Each agent or user message rendered in sequence below the separator
  • Chat input: Unified input at the bottom — used to send feedback during planned status (triggers plan revision) and to send messages to the running agent
  • Progress: Todo items with progress bar (persisted, survives page refresh)
  • Profile selector: Compact inline dropdown with icon
  • Translations: use "AI" not "智能体" for agent references