Skip to content

Scheduled tasks/watches hard-delete their own persistent Session after the first visible delivery, orphaning the definition permanently #1079

Description

@qihan-bot

Summary

A scheduled task or watch that holds a persistent Session minted by vibe task/watch add|update --create-session deletes that Session row from the database moments after its first successful visible delivery. The definition keeps its now-dangling run_definitions.session_id, so every subsequent scheduled fire dies at dispatch with agent session id not found: <id> — silently, with last_status=failed, 0s duration and empty result_text.

The delivery-finalization path treats any session anchor whose derived thread_id is None as a provisional channel-level anchor and clears it. It has no way to distinguish a throwaway provisional anchor from a durable --create-session definition anchor, because both satisfy that predicate.

In this environment a single pass of this behaviour orphaned 4 enabled definitions (enabled=1, deleted_at IS NULL, dangling session_id) — 3 have since been repaired, 1 is still outstanding. A further 17 soft-deleted definitions hold the same dangling session_id pattern but will never dispatch again, so they are not active failures. (A naive enabled=1 orphan query without an explicit deleted_at IS NULL filter will overcount, since vibe task remove only sets deleted_at and leaves enabled untouched — worth flagging for anyone writing a similar check.)

Related: #1060 (P2-2 describes watch bookkeeping that does not reflect whether a watch can actually still fire; this issue is one concrete mechanism that produces exactly that state). Not a duplicate of #953 (forever-watch run stampede).

Environment

  • avibe-os 3.0.8, installed 2026-07-27 18:22 UTC
  • Linux, Python 3.11
  • IM surface: Discord, channel scope
  • Source root for all line numbers below: /home/qihan/.local/share/uv/tools/avibe-os/lib/python3.11/site-packages/
  • The load-bearing evidence below (the 2026-07-28 12:01:32 clear) was captured on 3.0.8. Earlier events in the same pattern (the 07-27 10:01:15 clear, and the control-case Session created 2026-07-25T03:19:40) ran on the preceding release; its exact version is no longer recoverable (the dist-info was overwritten by the upgrade). What is known is that the 2026-07-27 18:22 upgrade moved the alembic revision from 20260716_0030 to 20260727_0038. This indicates the behavior spans at least two releases, not just 3.0.8.

Root cause

1. clear_provisional_source is decided solely by thread_id is None

core/scheduled_tasks.py:3451, inside _build_delivery_alias_strategy:

clear_provisional_source = session_target.thread_id is None and self._supports_threaded_delivery(session_target)

2. A durable --create-session anchor always derives thread_id = None

core/message_context.py:62-83, tail of thread_id_from_session_anchor():

legacy_prefix = f"{platform}_"
if not base_anchor.startswith(legacy_prefix):
    return None
thread_id = base_anchor[len(legacy_prefix):]
return thread_id if thread_id and thread_id != str(channel_id) else None

Anchors minted by --create-session have the form discord_<channel_id>:definition_<uuid4hex12>, generated by _session_anchor_with_suffix at vibe/cli.py:4115-4116. The base segment (split(":", 1)[0]) is exactly discord_<channel_id>, so the derived thread_id equals str(channel_id) and the function returns None. Condition (1) is therefore satisfied for every durable definition anchor.

3. On Discord the second conjunct is always true

modules/im/discord.py:187-188should_use_thread_for_reply() is unconditionally True in a channel context, so _supports_threaded_delivery is always True. clear_provisional_source is therefore True for every --create-session definition Session on Discord.

4. The clear fires only when a visible message was delivered

core/message_dispatcher.py:1882-1884, the sole call site of finalize_scheduled_delivery:

if scheduled_anchor_message_id and mutates_turn_lifecycle:
    self.controller.session_handler.finalize_scheduled_delivery(context, scheduled_anchor_message_id)

5. Finalization clears the source base

core/handlers/session_handler.py:455-483 (finalize_scheduled_delivery) reads clear_source and delegates to alias_session_base; core/handlers/session_handler.py:450-452:

if clear_source and source_base_session_id != alias_base_session_id:
    cleared = self.sessions.clear_session_base(resolved_source_key, source_base_session_id)

6. The clear is a hard DELETE

modules/sessions_facade.py:296config/v2_sessions.py:491-493storage/sessions_service.py:495-518 delete_agent_sessions()agent_sessions.delete().

This is a hard delete — not an archive, not a soft delete.

The defect: clear_provisional_source intends to clean up temporary channel-level anchors, but its only discriminator is thread_id is None, which cannot separate a temporary anchor from a durable definition anchor minted by --create-session. Both match.

Second defect that amplifies it: no reverse cleanup, no FK

run_definitions.session_id has no foreign key constraintstorage/models.py:180 is a plain Column("session_id", String, nullable=True) — and the hard-delete path performs no reverse cleanup. The definition is left holding a dangling id until the next scheduled fire, which then fails silently: last_status=failed, 0s runtime, empty result_text, no alert.

Notably, the same invariant is maintained on the UI deletion path: storage/workbench_sessions_service.py:904 archive_session does clean up referencing definitions, and its comment states the reason explicitly ("Deleting — not pausing — is deliberate: a paused definition could be re-enabled later and would then target a dead session"). So the invariant is enforced on one path and omitted on the other.

Evidence (logs)

Session sesyj3b7rvwdr, anchor discord_1494713026509668483:definition_78fc24cbfb20, used by a weekly scheduled task:

2026-07-28 12:00:01  Codex turn started: session=discord_1494713026509668483:definition_78fc24cbfb20
2026-07-28 12:01:31  (task posts its visible output to Discord)
2026-07-28 12:01:32  clear_session_base() - Cleared session base for discord::1494713026509668483:
                     discord_1494713026509668483:definition_78fc24cbfb20 (1 keys)

The task ran successfully and delivered; 91 seconds later its own Session was deleted.

The logs contain 10 such clear events against definition_* anchors, each matching the last-activity timestamp of one of 5 known-dead Sessions. Example: a clear at 07-27 10:01:15 corresponds to ses7hejj3hraw, whose task failed the next day at 10:00 with agent session id not found: ses7hejj3hraw.

Net effect in this environment: 4 enabled definitions orphaned (3 since repaired, 1 still outstanding), plus 17 soft-deleted definitions with the same dangling session_id that will never dispatch again.

Control case (pins the trigger to visible delivery)

Three definitions minted in the same batch, in the same scope, with the same anchor format, had different fates:

  • Two tasks that deliver a visible message → Session deleted.
  • One task, ab2dd2e16504, whose prompt instructs it to reply only with <silent>...</silent> → never produces a delivery message → scheduled_anchor_message_id is Nonefinalize_scheduled_delivery is never called → its Session sesgrd9mx7yu7 (anchor discord_1494713026509668483:definition_8339036c4bba, created 2026-07-25T03:19:40) has run successfully for several consecutive days and is still alive.

This isolates the trigger to "did the run emit a visible delivery message", and rules out time-based, quota-based, and GC-based explanations.

Reproduction

  1. In a Discord channel scope, mint a persistent Session for a scheduled task: vibe task add|update --create-session --scope-id discord::channel::<id> ...
  2. Confirm the row exists in agent_sessions and that run_definitions.session_id points at it.
  3. Let the task fire once, producing a visible message as output (not <silent>).
  4. Immediately after delivery, query agent_sessions — the row is gone.
  5. Wait for the next scheduled fire — it fails with agent session id not found, last_status=failed, 0s duration, empty result_text.

Expected behavior

clear_provisional_source should apply only to genuinely one-shot provisional anchors, and a hard delete of a Session should not be able to leave a live definition pointing at a dead id.

Possible directions (suggestions only — the maintainers should pick the right one):

  • In _build_delivery_alias_strategy, exclude anchors whose session_anchor contains :definition_.
  • Or invert the check against run_definitions.session_id: do not clear a Session that any enabled definition still references.
  • Independently: add reverse cleanup for run_definitions.session_id on the hard-delete path (or at minimum a startup self-check), bringing it in line with archive_session.

Impact

Every user who combines --create-session with a scheduled task or watch in a Discord channel scope — and, by the same code path, any IM platform whose should_use_thread_for_reply() returns True. For any such task that pushes a visible message, Session deletion after the first delivery is deterministic, not intermittent.

The only working configuration currently is session_policy=create_per_run with run_definitions.session_id IS NULL, i.e. the definition holds no Session reference at all — which forfeits cross-run conversational memory.

Ruled out

  • Automatic GC / retention. A whole-package search found no session TTL / max_age / keep_last policy; the only retention constant applies to on-disk backup files.
  • Schema migration. The 3.0.8 migration scripts only add columns and create indexes on agent_sessions.
  • A second --create-session call deleting the first. That path is insert-only, and anchors carry a uuid4 suffix so they cannot collide. The vibe session command group has no delete subcommand.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions