Skip to main content

State & Lifecycle

How Adjutant tracks runtime state, manages lockfiles, and transitions between lifecycle states.


State Files — state/

All runtime state lives under ADJ_DIR/state/. These files are gitignored and user-specific.

FilePurpose
adjutant.logUnified structured log. Format: [YYYY-MM-DD HH:MM:SS] [COMPONENT] message
telegram_offsetLast-processed Telegram update ID. Prevents replaying already-seen messages on restart.
listener.lock/Directory-based mutex. Only the process that successfully creates this directory can poll. Contains a pid file with the listener's PID.
listener.lock/pidThe authoritative PID of the running listener.
telegram.pidPID written by service.py start. Kept in sync with listener.lock/pid.
telegram_session.jsonSession ID for LLM backend chat continuity. Reused within a configured window; starts fresh after expiry.
telegram_model.txtCurrently selected Telegram model tier (cheap, medium, or expensive). Switched via /model.
rate_limit_windowSliding-window timestamp log for rate limiting.
pending_reflectMarker file indicating a /reflect confirmation is awaited.
last_heartbeat.jsonTimestamp and summary of the last /pulse or /reflect run.
usage_log.jsonlRolling token usage log for session and weekly estimates.
actions.jsonlJSONL audit log — one record per autonomous cycle or notification.
active_operation.jsonMarker for a currently running pulse or review. Written before the LLM backend call starts, removed when it finishes. Used by external clients (adjutant-web, Telegram) to observe running state.
notify_count_YYYY-MM-DD.txtToday's notification send counter. Enforces the daily budget. Resets at midnight automatically (date-scoped filename).

Lockfiles — KILLED and PAUSED

Two lockfiles at the root of ADJ_DIR control the system's operational state:

FileMeaningEffect
ADJ_DIR/PAUSEDSoft pauseListener keeps running but drops all incoming messages
ADJ_DIR/KILLEDHard stopListener will not start; all scripts check this before running

These are plain files — their presence/absence is the entire state. Managed by src/adjutant/core/lockfiles.py:

FunctionWhat it does
is_pausedReturns True if PAUSED exists
is_killedReturns True if KILLED exists
is_operationalReturns True if neither lockfile exists
set_paused / clear_pausedCreate / remove PAUSED
set_killed / clear_killedCreate / remove KILLED
check_killedReturns silently when not killed; raises when killed
check_pausedReturns silently when not paused; raises when paused
check_operationalComposite check — KILLED takes precedence over PAUSED

Active Operation Tracking

When a pulse or review starts, Adjutant writes state/active_operation.json:

{
"action": "pulse",
"started_at": "2026-03-18T21:30:00+00:00",
"pid": 12345,
"source": "cron"
}
FieldValues
action"pulse", "review"
source"cron" (CLI/crontab), "telegram", "adjutant-web"
pidProcess ID of the Python wrapper
started_atISO-8601 UTC timestamp

The file is removed in a finally block when the operation completes (success or failure). This allows any client to observe whether an operation is running by reading a single file — no need to hold open an HTTP connection or track in-memory state.

Staleness detection: If the marker is older than 30 minutes AND the recorded PID is dead, get_active_operation() treats it as stale and deletes it. This handles SIGKILL or other unclean shutdowns.

Managed by src/adjutant/core/lockfiles.py:

FunctionWhat it does
set_active_operation(action, source, adj_dir)Write the marker
get_active_operation(adj_dir)Read it, with staleness check
clear_active_operation(adj_dir)Delete it

Entry points that write the marker

TriggerCode pathSource value
adjutant pulse (CLI/crontab)lifecycle/cron.pyrun_cron_prompt()"cron"
adjutant review (CLI/crontab)lifecycle/cron.pyrun_cron_prompt()"cron"
Web dashboard buttonAPI spawns adjutant pulse → same as above"cron"
Telegram /pulsecommands.pycmd_pulse()"telegram"
Telegram /reflect + /confirmcommands.pycmd_reflect_confirm()"telegram"

Post-completion notification

After a successful pulse or review (exit code 0), run_cron_prompt() reads state/last_heartbeat.json and sends a Telegram notification with a summary of what was found. This is budget-guarded and best-effort — failures are silently swallowed.


Lifecycle State Machine

          adjutant start / adjutant startup


┌─────────────┐
│ OPERATIONAL │◄──── adjutant restart
└─────┬───────┘

┌────────┴────────┐
▼ ▼
adjutant pause adjutant kill /kill
│ │
▼ ▼
PAUSED KILLED


adjutant resume ──► OPERATIONAL
  • OPERATIONAL → PAUSED: adjutant pause or /pause. Creates PAUSED file. Listener keeps polling but drops messages.
  • PAUSED → OPERATIONAL: adjutant resume or /resume. Removes PAUSED file.
  • OPERATIONAL → KILLED: adjutant kill or /kill. Terminates all processes, creates KILLED file, disables cron.
  • KILLED → OPERATIONAL: adjutant startup. Detects and clears KILLED lockfile, restores crontab, then starts the listener fresh. Note: adjutant start will refuse if a KILLED lockfile is present.

External State Observation (adjutant-web)

The web dashboard derives a fourth display state, STOPPED, for its UI:

ConditionDisplayed State
KILLED file presentKILLED
PAUSED file presentPAUSED
No marker files + listener PID aliveOPERATIONAL
No marker files + listener PID deadSTOPPED

STOPPED is not a lockfile state — it's an observation that the process exited without leaving a KILLED or PAUSED marker. This happens when the listener is stopped manually or crashes.

Process detection checks state/listener.lock/pid first (authoritative), then state/telegram.pid (launcher-written), and verifies the PID is alive via kill(pid, 0).


Directory-Based Mutex

The listener lock uses a directory (state/listener.lock/) rather than a PID file directly. mkdir is atomic on POSIX filesystems — only one process can successfully create the directory. The PID inside listener.lock/pid is the real listener.

This pattern provides:

  • Race-free acquisition — no TOCTOU window between checking and creating
  • Stale lock detectionservice.py checks whether the PID in listener.lock/pid is still running before declaring the listener alive
  • Two-layer trackinglistener.lock/pid (written by the listener itself) and telegram.pid (written by the service manager) are kept in sync

Lifecycle Modules — src/adjutant/lifecycle/

ModuleWhat it does
control.pypause, resume, kill, startup. Clears KILLED lockfile on startup; creates/removes PAUSED. Emergency kill terminates all Adjutant processes by pattern and backs up then wipes crontab.
cron.pyRuns pulse and review prompts as subprocesses. Writes active-operation marker before start, clears on finish. Sends Telegram notification on success.
update.pyCompares VERSION against latest GitHub release, backs up, downloads, rsyncs new files into place. Personal files (adjutant.yaml, .env, identity/, knowledge_bases/) are never overwritten.