Files
hermes-agent/gateway/session_completion_notes.py
T
unsupportedpastels 2352d1d22c fix(gateway): write a local chat's cron result as a note, not a turn
A cron result admitted into a local chat ran as an automation turn over
the Bot Chat framing: a second model call over text that is already
finished. The admission now goes to the existing note executor
(session_completion_notes): a 'cron' producer is always noted, viewer or
not, so at the FIFO head the gateway writes the delivered text into the
chat's history (append_completion_note, keyed by the admission) and
settles it with no model call. The FIFO row is kept rather than a direct
write, which a live turn's lease would refuse. Failure notices are noted
the same way, marked diagnostic. The no-owner/unknown-chat/refusal/
timeout errors are unchanged.

Refs #143547
2026-10-25 19:18:26 -04:00

66 lines
4.0 KiB
Python

"""A completion for a local session no viewer is watching is written into it as a note (#134889 LC-7).
Main's CLI and TUI hosted async delegations and background processes in their own process, so once
the user left nothing started a turn for them. Under the gateway the owner outlives its viewers. The
completion is admitted like any producer input. Producers still coalesce a batch into one admission
under its primary identity, the siblings' identities in its descriptor: a lookup by any of them finds
it (``completion_admission``), results already admitted are left out of a new batch, and the one
outcome, note or turn, covers the whole batch. When that admission comes up for execution with no
viewer attached (the authority's own subscriber records) the executor records it in the session's
history, where the next turn sees it, and settles the admission without a model call. Queue order, restart survival and
exactly-once are the admission contract's. With a viewer attached it runs as the turn it always was,
except a local cron result, which is always a note; messaging, Kanban, ``/loop`` and heartbeat wakes
never reach here.
"""
import json
from functools import partial
# ``_completion_delivery_identity`` kinds: an async delegation's result, a background process's exit
# and its watch-pattern notices.
_NOTED_PRODUCERS = frozenset({'async_delegation', 'completion', 'watch_match', 'watch_disabled'})
# A cron result for a local chat (``session_cron.deliver_to_local_chat``) is finished text: a note
# with or without a viewer, since a turn over it would only spend a model call (#143547 CRON-11).
_ALWAYS_NOTED = frozenset({'cron'})
def _producer(identity):
try:
return json.loads(identity)[0]
except (ValueError, TypeError, IndexError, KeyError):
return None
def noted_completion(row):
"""*row* is a local completion admission: a note when it runs with no viewer attached."""
descriptor = (row or {}).get('payload', {}).get('local_automation_v1')
return descriptor is not None and _producer(descriptor['identity']) in _NOTED_PRODUCERS | _ALWAYS_NOTED
async def note_if_unviewed(authority, ref, row):
"""The claimed *row*'s response when it was written as a note instead of running, else None.
It is the FIFO head, so the note lands after every input committed before it and before every
input committed after; the viewer check and the decision share one loop step."""
if not noted_completion(row):
return None
from gateway.session_runtime_workers import tracked_write
live = authority.sessions.get(ref.session_id)
descriptor = row['payload']['local_automation_v1']
if live is None or (live.subscribers and _producer(descriptor['identity']) not in _ALWAYS_NOTED):
# The claim left a closed row closed for a note; the turn reopens it as any first turn does.
from gateway.session_local_recovery import reopen_local_session
await tracked_write(authority, partial(reopen_local_session, authority, ref))
return None
from gateway.session_viewer_close import _physical_target
metadata = {'completion_note': sorted(set(descriptor.get('identities') or ()) | {descriptor['identity']})}
metadata.update({key: descriptor[key] for key in ('display_text', 'notification_category') if key in descriptor})
target = _physical_target(authority, ref.session_id)
# Keyed by its admission: a claim is never replayed, and this keeps a write retry exact.
await tracked_write(authority, partial(
authority.db.append_completion_note, target, row['admission_id'], row['payload']['text'],
descriptor.get('display_kind', 'internal_notification'), metadata))
refresh = getattr(authority.runner, '_refresh_agent_cache_message_count', None)
if refresh is not None: # the owner's own row: a cached agent stays, as after a turn's writes
await refresh(live.route, target)
authority.pending_results[row['admission_id']] = {'result': {'final_response': '', 'messages': []}, 'usage': {}}
return ''