Compare commits

...
62 Commits
Author SHA1 Message Date
AIPass f62fbcfc17 Merge pull request #646 from AIOSAI/dev
Todo burn-down (S245-S246): seedgo readme/bypass fixes, dead trigger handler, dispatch-footer plan-close scope, backup docs sweep, README roster to 17 agents, /prep todo-reconciliation
2026-07-02 17:56:16 -07:00
AIOSAI 55bc4d0bb1 chore(release): bump 2.6.0 -> 2.6.1 for the DPLAN-0226/FPLAN-0289/TDPLAN-0010 batch merge
PATCH bump riding into PR#646 so main's merge commit carries the release version. pyproject + __init__ = 2.6.1 (must match the v2.6.1 tag). CHANGELOG [2026-07-02] leads with the release rollup + all 6 CI-stabilization fixes.
2026-07-02 17:41:16 -07:00
AIOSAI 194410d467 fix(skills): deterministic LF in test_streaming byte-offset tests (Windows CRLF)
test_partial_line_not_consumed asserted +1 byte for the newline, but write_text() text mode translates \n->\r\n on Windows (2 bytes) -> off-by-one, failing windows-setup only. Switched both transcript write sites to write_bytes() for deterministic LF cross-platform. Production _tail_transcript_bytes is already CRLF-safe (reads rb, splits b'\n', strips \r) — test-only fix.
2026-07-02 16:46:47 -07:00
AIOSAI e5e740765d fix(spawn): track template scaffolding orphaned by builder->aipass_framework rename
.gitignore exceptions still pointed at templates/builder/ after the TDPLAN-0010 rename (13463c0), so DASHBOARD.local.json + 10 other template dirs/files under templates/aipass_framework/ were silently gitignored — on disk (dirty tree passed) but absent in clean clones/CI. Result: spawn produced no DASHBOARD.local.json and test_full_spawn failed only in a clean checkout. Fixed all 23 .gitignore exception paths + tracked the now-visible template files (all placeholder/seed content: {{BRANCHNAME}}/{{DATE}}/{{CITIZEN_NUMBER}}).
2026-07-02 16:15:05 -07:00
AIOSAI e92dcaff12 fix(ci): restore green — advisory template checker no longer gates audit + 3 test/module regressions
Root-cause fixes for PR#646 red (dev broke after DPLAN-0226/FPLAN-0289/TDPLAN-0010 batch):
- seedgo: branch_audit honors ADVISORY (template_check no longer averaged into gate) + presence_gate added to hooks-snapshot fixture (4 tests)
- hooks: cc_sessions README entry + seedgo modules bypass (reads external ~/.claude, not branch data)
- spawn: retire passport(disabled).py/passport_ops(disabled).py to .archive/ (disabled suffix kept broken cross-import visible to type checker)
- ai_mail: broker-fd test gives testbranch a real .trinity/passport.json for the new marker-walk resolution (f914ab6)
2026-07-02 15:47:53 -07:00
AIOSAIandClaude Opus 4.8 e1ac4e365f docs(prompts): .trinity entry-cap awareness — point at live *_meta line, no hardcoded numbers
Navmap (@hooks): one bullet in the Memory section — caps are hook-enforced,
the live cap is rendered in each file's *_meta line; read it before writing,
draft to ~80%, one-pass rewrite if rejected. Devpulse branch prompt: same
behavior, explicitly notes caps are NOT listed (single source =
memory.config.json → entry_limits, auto-rendered by @memory's tab_renderer).
Change the config → enforcement + in-file docs follow mechanically; prompts
never go stale.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3mZT61WsKVN3srCwVDBiW
2026-07-02 05:41:24 -07:00
AIOSAIandClaude Opus 4.8 301f3fcb93 feat(backup): share <file> — single-file Drive upload + shareable webViewLink (FPLAN-0298)
New 'drone @backup share <file_path> [--public]': uploads a single file to Drive
(AIPass Backups/Shared), sets a read permission (default: restricted to the
authenticated user; --public: anyone-with-link), returns the webViewLink
(webContentLink fallback). Reuses upload_single_file + DriveClient; idempotent
via _find_existing_file; fail-loud on every path. 21 new tests, all Drive API
mocked (zero live calls). Existing commands untouched. DPLAN-0230 v1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3mZT61WsKVN3srCwVDBiW
2026-07-01 19:56:56 -07:00
AIOSAIandClaude Opus 4.8 a5ede6fbf4 feat(skills/telegram): opt-in live streaming edit-in-place for TG bot (FPLAN-0297, DPLAN-0229)
Repurpose the heartbeat into a ~2s transcript-tail loop that edits the "Processing" message in place (block-level: thinking/tool/text), plain text, coalesced, no-op-skipped, 429 retry_after aware, with 4096 rollover. Opt-in per-bot "stream" flag, default OFF; batch path byte-for-byte unchanged. @hooks reviewed: no change needed (already edits processing_message_id for the single-chunk final). Race hardened: re-check delivered before each edit. 37/37 streaming + 653/653 TG tests green; live-proven on the devpulse bot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q3mZT61WsKVN3srCwVDBiW
2026-07-01 18:40:32 -07:00
AIOSAI 337f31ddab fix(prax): per-bot telegram log attribution via AIPASS_BOT_ID/AIPASS_LOG_NAME in get_caller_info — bots no longer collapse into shared skills_base_bot.log 2026-07-01 16:07:22 -07:00
AIOSAI fca1105ed9 docs(pkg): aipass/__init__ docstring clone-only, drop 'pip install aipass' (TDPLAN-0010) 2026-07-01 09:49:36 -07:00
AIOSAI df5a1493bb docs(readme): remove pip entirely — clone-only install, badges + version + uninstall purged (TDPLAN-0010) 2026-07-01 09:44:23 -07:00
AIOSAI fd41320e3c chore(spawn): seedgo bypass for stale post-rename 'builder' architecture finding (TDPLAN-0010) 2026-07-01 09:11:59 -07:00
AIOSAI f914ab616e refactor(drone): .trinity/-marker walk-ups replace src/aipass hardcodes — portable resolution + access checks (TDPLAN-0010, FPLAN-0296) 2026-07-01 08:54:05 -07:00
AIOSAI 13463c0ce0 feat(spawn): rename builder->aipass_framework, {{CITIZEN_CLASS}} placeholder, retire birthright, per-project registry targeting (TDPLAN-0010, FPLAN-0294) 2026-07-01 08:16:54 -07:00
AIOSAI 5a3d01efb1 feat(aipass): aipass init template selector — empty-project default + stage gating (TDPLAN-0010, FPLAN-0295) 2026-07-01 08:09:30 -07:00
AIOSAI a2812abc90 refactor(ai_mail): portable find_repo_root() marker-walk replaces fixed-depth _REPO_ROOT (TDPLAN-0010 foundation, FPLAN-0293) 2026-07-01 07:26:16 -07:00
AIOSAIandClaude Opus 4.8 2a5a370185 fix(drone): --json pass-through uses sys.stdout.write, no Rich mid-string wrap (td-49)
--json was routed through Rich console.print(), which defaults to width 80 on
a non-TTY and hard-wraps mid-string, producing invalid JSON (e.g. 'Security
\nScan'). Write raw JSON with sys.stdout.write() in the pass-through paths
(drone.py + router.py); keep Rich for drone's own human UI. Verified live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EccxENcB3KtyT9XuT4ybPz
2026-07-01 06:12:02 -07:00
AIOSAIandClaude Opus 4.8 61f958c17e feat(seedgo): stale-template audit checker — advisory flag for unrendered template markers in local prompts/config (DPLAN-0228)
New auto-discovered advisory standard: warns (never blocks) when a branch
still carries unrendered template markers, so a citizen that never customized
its scaffold no longer fails silently. Adds template_content.py + template.md
standard doc + test_template_check.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EccxENcB3KtyT9XuT4ybPz
2026-07-01 06:11:51 -07:00
AIOSAI f6cbe34b61 feat(bridge): DPLAN-0226 unified TG<->CC bridge — CC-native session discovery, live-proven round-trip (FPLAN-0290/0291/0292) 2026-07-01 04:33:05 -07:00
AIOSAIandClaude Opus 4.8 91cb59154d fix(e2e): rm_gate block contract is exit 2, not exit 0 (FPLAN-0289 CI)
beb048d made the Claude bridge propagate a hook's exit code so presence_gate's
UserPromptSubmit block can cancel a prompt. Every security gate
(rm/git/edit/subagent/presence) already returned exit_code 2 for a block, but
the old bridge swallowed it — so test_t2a_rm_gate_blocks pinned exit 0. Update
the e2e contract to expect exit 2 + the stdout decision JSON, which is the real,
live-proven block signal.

Sole CI red on the P1-activation commits: 1 failed, 10116 passed. Fixes both
e2e-wheel (all 3 OSes) and Windows Test (full suite includes tests/e2e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmDj4eu8aFFVP2rapovqkg
2026-06-30 04:36:01 -07:00
AIOSAIandClaude Opus 4.8 dc5c1d23fc fix(hooks): presence keys the persistent claude session PID, not the ephemeral hook PID (FPLAN-0289 P1)
Final activation fix. The gate recorded os.getpid(), but the hook runs as a
short-lived subprocess (python3 -> sh -> claude) that dies in milliseconds, so
every later session saw the prior holder's PID as dead, reclaimed it, and never
blocked. claim()/release() now resolve the owning session via _resolve_session_pid():
walk the /proc parent chain (PPid from /proc/<pid>/status) up to the comm=claude
ancestor and record THAT pid. Fails OPEN if no claude ancestor (non-Linux, or an
unexpected process tree). handle_stop() is now a no-op: Stop fires every assistant
turn, so releasing there would free the slot mid-session; stale-detection (the
claude pid going away) reclaims on real exit instead.

PROVEN LIVE — real two-session interactive test (the unit blind spot that a
long-lived-holder harness masks):
  session 1 in branch X resolves chain 731814:python3 -> 731813:sh -> 730933:claude,
    records pid 730933 (comm=claude, cwd=X); work_dir=X, cwd_match True.
  session 2 in branch X resolves its own claude pid, sees X occupied by live
    730933, and Claude Code blocks the prompt in the UI:
    "UserPromptSubmit operation blocked by hook: ztest... already live at PID 730933
     - attach, do not spawn."
  session 2 did NOT clobber session 1; a different branch is unaffected.
Added 9 tests modelling the ephemeral-PID lifecycle (54 presence tests total);
seedgo @hooks 100%.

Activation is a machine-local provider-settings change (presence_gate wired first
in ~/.claude/settings.json UserPromptSubmit) — not tracked in the repo; the code
landing here is what makes it correct.

Design: DPLAN-0225 / FPLAN-0289 P1. Build by @hooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqoxFdbDMirzkQ5kjRVVos
2026-06-29 18:38:02 -07:00
AIOSAIandClaude Opus 4.8 beb048dadf fix(hooks): presence_gate keys per-branch via hook_data cwd; engine propagates block exit code (FPLAN-0289 P1)
Activation fixes for the single-session presence gate. Two bugs blocked it,
both caught by live testing after all units were green:

1) Wrong branch key. presence_gate used Path.cwd().name, but under the Claude
   Code bridge the hook process cwd is the project root, so every session keyed
   to "AIPass": the gate never enforced one-live-session-per-branch and would
   have rejected sessions project-globally (any 2nd interactive session in any
   branch). Now _resolve_branch(hook_data) reads the event payload's cwd (the
   real session dir) and walks up to the branch root (.trinity/ or apps/),
   mirroring branch_loader. Applied in handle() and handle_stop().

2) Block never reached Claude Code. engine.dispatch() returned only stdout, so
   the bridge could not surface a non-zero exit. dispatch() now returns
   (stdout, exit_code) and the bridge exits with it on a block. Pre-existing gap
   affecting every block hook on every event; now fixed engine-wide. An
   intentional block (exit 2 + {"decision":"block"}) propagates; a crashing hook
   (exit 2, non-JSON stdout) is logged and falls through, so the gate fails open.

Proven: 110 hooks unit tests pass (6 new for branch resolution); seedgo @hooks
100%, no type errors. Live bridge end-to-end (real live holder + real bridge):
duplicate into a held branch -> exit 2 + block reason naming the branch; a
different free branch -> exit 0 (per-branch isolation intact). Gate remains
dormant: not yet wired into provider settings.

Design: DPLAN-0225 / FPLAN-0289 P1 activation. Build by @hooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CqoxFdbDMirzkQ5kjRVVos
2026-06-29 17:46:09 -07:00
AIOSAIandClaude Opus 4.8 8d775b4bd2 feat(skills): TG bot follows PRESENCE pointer, retire legacy own-spawn (FPLAN-0289 P2)
The Telegram bot becomes a thin durable relay: it follows the live Claude session
via .ai_central/PRESENCE.central.json and never starts its own brain.
ensure_tmux_session resolves in 3 strategies (central pointer -> shared_session
config -> already-running own tmux), re-binding to the live session on every message
(handover-safe). The legacy AIPASS_SESSION_TYPE=telegram own-session spawn is retired:
replaced with a clear "no live session to mirror" error; an absent/stale pointer falls
back gracefully and never starts a session. on_session_create (which injected "hi"
after self-start) removed as obsolete -- attaching to a live session injects nothing.

New helpers: _find_presence_file, _read_presence_pointer (PID-liveness via os.kill),
_find_tmux_for_presence (attach_handle preferred, tmux-CWD-scan fallback).

601 TG + 252 skills tests pass; seedgo Unused_Function 100% (overall 99%; residual is
a pre-existing Json_Handler item in unrelated modules). Live mirror proof to follow.
Design: DPLAN-0225 / FPLAN-0289 P2. Build by @skills.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFihce1oLtp6UDAPGryYSv
2026-06-29 14:54:50 -07:00
AIOSAIandClaude Opus 4.8 13983b614a fix(hooks): presence tests cross-platform — patch _presence_lock not fcntl (FPLAN-0289 P1)
Windows CI was red on f460cd5: the patch_flock fixture patched presence.fcntl,
which only exists on POSIX (msvcrt on win32), erroring all 16 presence-test
setups. Now patches the platform-agnostic _presence_lock context manager
(-> nullcontext per call) and skips the inherently-POSIX flock-acquire test on
win32. Dormant prod code unchanged.

705 tests pass, seedgo 100%. Fix by @hooks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFihce1oLtp6UDAPGryYSv
2026-06-29 14:16:02 -07:00
AIOSAIandClaude Opus 4.8 f460cd577e feat(hooks): presence service + single-session gate, dormant (FPLAN-0289 P1)
One live Claude runtime per branch. presence.py manages .ai_central/PRESENCE.central.json
(claim/release/refresh, PID + /proc/cwd liveness, stale-reclaim, PID-guarded release so a
non-holder can never release the holder). presence_gate.py: UserPromptSubmit blocks a
duplicate (exit 2 + decision:block), Stop releases; skips sub-agents + dispatched/daemon.

SessionStart can't block in Claude Code (inject-only) → gate is UserPromptSubmit, like the
edit/git gates. NOT wired into hooks.json yet — dormant, zero behavior change until enabled.

705 tests pass, seedgo 100%. Live cross-process block + PID-guard verified (devpulse).
Design: DPLAN-0225. Next: P2 telegram relay follows the pointer (@skills).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFihce1oLtp6UDAPGryYSv
2026-06-29 13:51:48 -07:00
AIPass 90157ed29e Merge pull request #647 from AIOSAI/dependabot/github_actions/actions/setup-python-6.3.0
ci(deps): bump actions/setup-python from 6.2.0 to 6.3.0
2026-06-29 10:58:32 -07:00
AIOSAI 2217b96054 fix(skills): Windows CI — mirror transcript slug strips backslashes; tests drop platform tricks (TDPLAN-0009)
base_bot.py _resolve_active_transcript: slug replaces both backslash and / (Windows work_dir paths left backslashes → wrong projects_dir; POSIX no-op). test_mirror_session.py: slug matches production + mkdir exist_ok=True (dir pre-created on Windows → WinError183). test_monitor.py: mock _save_monitor_subscription→False instead of /dev/null OSError trick. 578 TG + 252 skills green on Linux; Windows-safe by construction. Fix by @skills, verified by devpulse.
2026-06-29 10:46:54 -07:00
AIOSAI bd57573764 fix(seedgo): audit always ignores .archive/ — full stop (Patrick directive)
readme_check.py: _count_test_functions now skips any path with a SOURCE_SKIP_DIRS segment (.archive) — root cause of the local-vs-CI test-count mismatch (daemon counted 486 local incl archived dead tests vs 300 in CI clean checkout). test_quality_check.py: _find_test_files_broad replaced __pycache__-only skip with _should_skip_dir() on all path parts. Daemon 486→300, audit 100%, 17-branch audit zero regressions. CI-neutral (clean checkout has no .archive). Fix by @seedgo, verified by devpulse.
2026-06-29 10:30:37 -07:00
AIOSAI 8d2dcdcc77 fix(daemon): README test count 486→300 (live count; 486 wrongly included .archive dead tests)
CI's clean checkout counts 300 live tests (matches pytest); README claimed 486 because the count included 6 archived dead-test files under tests/.archive/ (186 tests). 300 is the true live/CI count. Root-cause framework fix (audit must always ignore .archive) dispatched to @seedgo separately.
2026-06-29 10:17:20 -07:00
AIOSAI 3d66e8397b fix(daemon): seedgo 100% — archive dead cron orphans, queue introspection bypass, exception-contract test, README count (TDPLAN-0008)
Diagnostics 65%→100%: archived dead orphans scheduler_cron.py + modules/scheduler_ops.py (+ their test_scheduler_cron.py) — old cron scheduler superseded by queue/run_tick (S254), imported only by already-archived files; cleared 7 pyright unresolved-import errors. Introspection 98%→100%: bypass queue.py (td-47 — bare invocation renders operational Rich table). Test_quality 98%→100%: added test_invalid_mode_raises. README count fixed. Cleaned 6 stale bypass entries. Audit @daemon=100% (38 standards), 300 tests green. Fix by @daemon, verified by devpulse.
2026-06-29 10:00:07 -07:00
AIOSAI 9e988a63b3 fix(daemon): CI green — .archive-existence tests → import-path assertions; bare 'queue' renders Rich table (TDPLAN-0008, td-47)
test_scheduler_bot.py: replaced test_data_files_archived + test_handler_files_archived (asserted gitignored .archive paths → failed in CI clean checkout) with test_task_registry_not_importable + test_actions_registry_not_importable (assert ImportError — env-independent). queue.py: bare 'drone @daemon queue' now renders the Rich table instead of print_introspection (matches --help). 24 scheduler_bot tests green. Fix by @daemon, verified by devpulse.
2026-06-29 09:37:43 -07:00
AIOSAI 88e99efe4c feat(skills,hooks): TDPLAN-0009 Telegram session mirror — bidirectional, live-proven @api
@skills: dup-spawn fix (run() lock-collision exit 0), attach_only + launch_mirror_session (--dangerously-skip-permissions), _config_chat_id init bug + /proc active-transcript baseline, systemctl start. @hooks: extract_mirror_turn cursor clamp + baseline reset on delivery, mirror-file unlink guards. +4 test files. 578 skills / 112 hooks green.
2026-06-29 08:53:03 -07:00
dependabot[bot] aea90da5c6 ci(deps): bump actions/setup-python from 6.2.0 to 6.3.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.2.0 to 6.3.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-27 08:02:22 +00:00
AIOSAI 4363f9824a fix(skills): SchedulerBot.run() crash-looped on real ExecStart — wrap super().run() (TDPLAN-0008)
- run() called a non-existent self._poll_loop() AND duplicated the parent lifecycle incompletely (missing signal handlers, offset load/save) — bot exited 1 in <1s under systemd. Now wraps super().run() with digest start/stop only.
- Fixed broken 'from .json import json_handler' (relative path did not exist) → absolute import; log queue_requested op so json_structure standard is met by use, not noqa
- Added missing docstrings: handle_message / handle_file / get_custom_commands
- ROOT CAUSE: 26 unit tests never executed run() (it blocks on polling) → green tests, failing ExecStart (key-learning #77: test the real ExecStart, not the drone/mocked form). Verified live: bot now active+polling via systemd, 26 tests + seedgo 30 still green.
2026-06-25 17:04:31 -07:00
AIOSAI d252403d37 feat(skills): Scheduler Bot Phase 2 — dedicated bot /queue + hourly digest (TDPLAN-0008)
- SchedulerBot(BaseBot): rejects free-text (NO tmux/Claude spawn), /queue shells 'drone @daemon queue --json', hourly digest thread, chunked output
- base_bot __main__: _BOT_CLASSES maps bot_id->SchedulerBot for systemd launch; passes config chat_id for digest target
- bot registered (telegram-bot@scheduler), systemd unit installed (NOT started)
- 26 new tests (519 telegram / 252 skills green), seedgo 99%

fix(daemon): queue --json emits valid JSON at module level — flatten prompt_preview (collapse newlines) + soft_wrap/markup off. Verified valid via 'python -m'. NOTE: the drone router still re-wraps sub-command stdout at width 80, corrupting machine --json for ALL consumers routed through drone (separate @drone core bug; skills mitigates with strict=False JSON parse).
2026-06-25 16:56:56 -07:00
AIOSAI b51ac87eb7 feat(daemon): Scheduler Bot Phase 1 — unified queue + status capture + lifecycle telegram pings (TDPLAN-0008)
- One queue: archive dormant task_registry + actions_registry (+5 tests) to .archive/, retire schedule/actions CLIs; .daemon/schedule.json is now the single source
- Status capture: runstate gains last_status/last_error/last_success_at/last_failure_at; persisted on success AND failure paths (was success-only)
- Unified view: drone @daemon queue + --json (frozen schema), aggregates .daemon/*.json joined to runstate
- Lifecycle pings: un-archived telegram_notifier wired to @skills send_telegram_notification (fail-soft, per-job notify flag, zero calls on empty ticks)
- 24 new tests (327 pass), seedgo 98%; verified live end-to-end (queue --json schema + real telegram delivery via daemon wrapper)
2026-06-25 16:36:10 -07:00
AIOSAI 1d3094acee fix(ai_mail): escape systemd cgroup for daemonized wakes (td-48) 2026-06-25 08:15:37 -07:00
AIOSAI 5b7fa2d4ad docs(daemon): self-sufficient run --help (schema/types/example) + README scheduling section (FPLAN-0287) 2026-06-25 07:10:49 -07:00
AIOSAI f832a558cd feat(daemon): systemd user timer auto-runner — decentralized scheduler fires hands-off (FPLAN-0287) 2026-06-25 06:31:05 -07:00
AIOSAI a777251ab7 fix(skills): bypass telegram ported-but-unwired fns (seedgo-audit) + document
DPLAN-0218 pulled telegram into the seedgo gate, surfacing 16 unused_function
flags across 8 handlers. They are ported-but-unwired (S249), not dead — pending
DPLAN-0220 wiring. Added name-scoped unused_function bypasses citing DPLAN-0220,
documented each in SKILL.md -> Ported-but-unwired (remove bypass as wired).
@skills 100%.
2026-06-25 04:17:19 -07:00
AIOSAI 1794c8f954 fix(spawn): use json_handler.read_json for passport in adopt path
core.py adopt-path read the passport via json.loads(read_text()) — a direct
file op that fails the json_handler standard and the CI seedgo-audit gate.
Switch to json_handler.read_json() (matches the pattern ~90 lines above),
drop the now-unused 'import json as _json'. @spawn 100%; 315 spawn tests green.
2026-06-25 04:01:50 -07:00
AIOSAI 7e9c0cfca7 fix(telegram): make Windows-unmasked tests cross-platform
Guarding the fcntl import let Windows collection succeed, which surfaced 3
telegram tests that had never run on Windows — all test-portability bugs:
- log_streamer byte-count broke on CRLF -> fixture writes newline=''
- bot_registry write-failure used Unix-only /proc -> file-as-parent (all OS)
- validate_bot_config rejected POSIX work_dir on Windows (Path.is_absolute is
  host-dependent) -> test absoluteness under PurePosixPath OR PureWindowsPath
493 telegram tests green on Linux; ruff clean.
2026-06-25 03:44:26 -07:00
AIOSAI ec6e966137 fix(telegram): guard fcntl import for Windows — unblock CI test collection
bot_registry did a bare 'import fcntl' (POSIX-only); on Windows the 8
telegram test modules importing it failed at collection (ModuleNotFoundError),
reddening Windows Test on recent PRs. Guard the import and route flock calls
through no-op-on-Windows _lock/_unlock helpers. 246 telegram tests green.
2026-06-25 03:18:58 -07:00
AIOSAI fbf102c636 feat(memory,spawn): self-documenting .trinity state-tabs + todo delete-on-done discipline
- .trinity sections carry config-sourced rollover/keep/char-cap tabs; @memory owns
  values (render_all_meta_tabs), @spawn resolves placeholders at create via spawn_pusher
- todos confirmed rollover-exempt; vestigial rollover-config entry removed
- prep/memo/startup (Claude+Codex): delete finished todos, don't leave status:done
- @memory README documents the system
- FPLAN-0285, FPLAN-0286
2026-06-25 03:00:36 -07:00
AIOSAI be8f87d6fb feat(prax): monitor→Telegram relay (prax_monitor bot) + fix service feedback loop
Mirrors the live 'drone @prax monitor run' Mission-Control feed to a dedicated
Telegram bot (DPLAN-0221). New monitoring/telegram_relay.py taps _render_event,
batches every 5s (4000-split, 150 flood-cap, fail-silent-once), gated by
--relay/env so local monitor stays console-only. Reboot-survivable
prax-monitor.service. 937 prax tests green (31 new).

Deploy fixes (devpulse): ExecStart -> 'monitor run' (module __main__ rejects
'run all --relay'); service log moved out of system_logs/ to ~/.aipass/ to break
a monitor<->@trigger feedback loop.
2026-06-25 00:10:06 -07:00
AIOSAI 97b884bef7 feat(skills): prax-monitor v1 on Telegram — /monitor system-wide log subscription
Revives the old prax-monitor capability as a feature of the existing
@aipass bot (no 2nd bot, no new credential). /monitor on|all|off|status
on base_bot; subscribed chat persisted to @api (survives restart) and
boot-started on startup. LogStreamer gains system_wide glob + level_filter
(default WARNING/ERROR/CRITICAL, all=passthrough). 33 new tests,
telegram 493/493, skills 252/252, seedgo 98%.

Route B (true AS-WAS @prax event-feed relay) tracked separately.

DPLAN-0221
2026-06-24 20:48:17 -07:00
AIOSAI d41ecd9877 fix(skills,ops): deploy @aipass telegram bot under systemd + fix unit log path
The ported telegram-bot@.service logged to a non-existent ~/system_logs
(would crash-loop the service); point StandardOutput/StandardError at
<repo>/system_logs where the app already logs. Then installed the unit,
enable --now + loginctl enable-linger so the @aipass mother-bot runs as a
proper user service with reboot survival and a one-line restart. Startup
log confirms: Telegram API OK, Command menu set (6 commands), poll loop,
tmux session preserved, NRestarts=0.

DPLAN-0220
2026-06-24 17:28:44 -07:00
AIOSAIandClaude Opus 4.8 bf301bc231 feat(telegram): /help + command menu — startup populate, single source, enriched (DPLAN-0220)
Resolves the build_botfather_commands design call (Patrick: KEEP, not delete).

POPULATE: base_bot sets its Telegram command menu on startup (setMyCommands)
after verify_connection, so every bot — base or minted — gets a populated
slash-menu, not just create_bot'd ones (the live @aipass was hand-launched
and had none).

SYNC: build_botfather_commands (telegram_standards) is now the single source
feeding base_bot-startup AND create_bot; DEFAULT_BOT_COMMANDS retired. The
Telegram menu and /help list the same commands incl. /create + /cancel.

ENRICH: friendlier command descriptions + /help intro/footer.

Wiring the builder (vs deleting it as 'dead') lifted Unused_Function 92->93%.
6 new tests (menu==help sync, enriched copy, startup-menu, custom cmds);
telegram 460/460, skills 252/252. Running bots need a restart to pick up the
startup menu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 16:59:07 -07:00
AIOSAIandClaude Opus 4.8 9af4c8ac05 feat(telegram): close GAP1 — create_bot persists config to @api so minted bots start (DPLAN-0220)
bot_factory.create_bot now calls set_secret('telegram', bot_id, config,
as_json=True) right after building the config (fail-loud on OSError), so a
newly-minted bot's token reaches the @api store that load_bot_config reads.
The disk write is downgraded to a non-fatal shadow; registry now records
bot_token_ref='@api:telegram/{id}' (TG-LIFE-069).

Proven: new TestCreateBotRoundTrip — create_bot -> @api -> load_bot_config
returns the persisted config; + a fail-loud test (set_secret OSError ->
create_bot returns None). Telegram 454/454, skills 252/252.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 16:49:06 -07:00
AIOSAIandClaude Opus 4.8 0096aef1d2 feat(telegram): port wave-1 fixes + @api set_secret write-door (DPLAN-0220)
Surfaced by a full completeness audit of the telegram skill against
TELEGRAM_PORT_MAP.md (366 tags, ~83% ported, 452/452 tests green).

@api — in-process set_secret(provider, slug, value, *, as_json) writer
mirroring get_secret (0o600 files / 0o700 dirs, no stdout echo). The store
was read-only; this is the GAP1 enabler the telegram mother-bot needs to
persist a created bot's config. 515 @api tests, seedgo 100%.

@skills telegram wave-1 (fix-forward, no deletions):
- GAP2: bot_factory + telegram-bot@.service launched a non-existent
  ~/.venv/bin/python3; now sys.executable -m ...base_bot (+ lib/__init__.py
  and lib/telegram/__init__.py for package resolution).
- Reboot survival: enable_service now installs the unit to
  ~/.config/systemd/user/ + daemon-reload (was never installed).
- GAP9: gitignore lib/telegram/.local/ so runtime state stops leaking to git.
- prax-monitor: log_streamer now resolves repo-root system_logs (honoring
  AIPASS_TEST_LOG_DIR) instead of a hardcoded ~/system_logs.

Verified: telegram 452/452 green (twice), base_bot imports via -m.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 16:39:08 -07:00
AIOSAI 882da7cdfc refactor(skills): relocate skill library to src/aipass/skills/lib/ — rename catalog/, move telegram in, archive orphan fixtures, retire .aipass/skills/
Unifies all 6 first-party skills under lib/ (built-in tier). Fixes telegram not
being cross-branch discoverable (was in cwd-relative .aipass/skills/). Built-in
discovery path catalog->lib; telegram conftest parents[6]->[5]; .service
ExecStart, seedgo bypass + test paths updated. Packaging/imports/gitignore
unaffected (stays under src/aipass). 252/252 tests green, cross-branch discovery
verified. DPLAN-0218.
2026-06-24 14:24:28 -07:00
AIOSAIandClaude Opus 4.8 57767cc2a9 fix(api): render 4 module --help functions in Rich (caught by tightened CLI checker)
The CLI help-checker fix (4d41065) immediately surfaced the same
console.print(parser.format_help()) laundering in 4 @api modules on its first
audit run — exactly the latent stragglers the static-scan loophole had been
hiding. Rewrote each print_help() to hand-rolled Rich markup (content was
already in the argparse epilogs); removed the help-only argparse parsers.

- api_key.py, usage_tracker.py, google_client.py, openrouter_client.py
- @api audit Cli + Overall back to 100% (38/38), 504 tests pass, no bypass

DPLAN-0217 (follow-on).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 12:26:34 -07:00
AIOSAIandClaude Opus 4.8 4d4106505f fix(seedgo,ai_mail): close CLI help-checker loophole + render ai_mail --help in Rich
seedgo's cli/help_text/introspection standards are static source scans — they
confirm a print_help function, console.print, and --help wiring exist, but never
execute --help. So a module could score 100% while rendering raw argparse.
ai_mail did exactly that via console.print(parser.format_help()), laundering
argparse plain text through the approved console API and dodging the existing
parser.print_help() ban.

- seedgo: cli_check now flags .format_help(); cli.md/cli_content.py name it
  alongside print_help(); +2 regression tests (1095 pass, self-audit 100%)
- ai_mail: rewrote print_help() to hand-rolled Rich (737 tests pass); --help now
  renders Rich with no raw argparse, Cli back to 100% legitimately
- behavioral --help check (run it, assert not raw argparse) noted as a follow-up

DPLAN-0217.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 11:58:53 -07:00
AIOSAI 4af5b8bf63 refactor(backup): move .backupignore seed from code to templates/ data file
Backup was the outlier — its default .backupignore content was hardcoded as the
BUILTIN_IGNORES Python list + assembled in _build_backupignore(). Moved it to a
template DATA FILE (backup/templates/backupignore.template), matching the AIPass
convention (flow/spawn/memory all keep templates as files).

- New: templates/backupignore.template (header + patterns, incl logs/).
- _build_backupignore() reads the template via __file__-relative pathlib and
  RAISES FileNotFoundError if it's missing — never silently empty (an empty
  .backupignore = back up everything = crash). Behavior-preserving otherwise.
- Retired BUILTIN_IGNORES (only setup.py consumed it). Runtime load_spec path
  untouched.
- Tests expanded (30 pass): per-pattern template assertions + reads-template +
  raises-on-missing-template. Docs/comments repointed to the template.

seedgo @backup 100%. td-30.
2026-06-24 09:39:51 -07:00
AIOSAI 70cf31eb3e docs(backup): document seed-vs-runtime ignore architecture + add logs/ default
Confirmed (via @backup) the two-layer ignore model and wrote it down so it stops
getting re-discovered:
- BUILTIN_IGNORES (patterns.py) = the SEED that generates a new project's
  .backupignore at register; never consulted at backup time.
- .backupignore (via load_spec) = the runtime source of truth. No static
  fallback exists, so the seed is safety-critical — an empty .backupignore backs
  up everything (.venv, node_modules, .git) and can crash the machine.

Added a 'How Ignores Work' README section + code comments on BUILTIN_IGNORES and
load_spec. Added logs/ to the seed so new projects exclude log dirs (prax .jsonl
output) by default, not just *.log files, with a test. seedgo @backup 100%.

td-27.
2026-06-24 09:21:34 -07:00
AIOSAI 451c8a0ee9 docs: README roster currency (17 agents) + /prep todo-reconciliation step
README: added the 3 missing agents (@daemon, @skills, @commons) to the tree and
tables, normalized the agent count to 17 everywhere (was an inconsistent 13/14).
@daemon -> Quality & operations; new 'Capabilities and community' group for
@skills + @commons (td-28).

/prep: both the Claude command and Codex skill mirror gained a 'Reconcile todos
against reality' step — audit every open todo against the actual system and close
what's verifiably done, catching past-session work that was never closed.

CHANGELOG updated.
2026-06-24 08:48:44 -07:00
AIOSAI c1dba78d31 fix(ai_mail): scope dispatch-footer plan-close to worker's own plan
The standard email footer told dispatched agents 'CLOSE FPLAN -> drone @flow
close <plan_id>', which led them to close the orchestrator's master/parent plan
referenced in their brief (bit us in FPLAN-0260). Reworded to 'CLOSE YOUR PLAN
-> ... this task's plan only, never the master/parent': a worker still closes the
sub-plan handed to it, the master stays the orchestrator's to close on completion.

td-6. Footer string + test assertion; 737 ai_mail tests pass.
2026-06-24 07:21:35 -07:00
AIOSAIandClaude Opus 4.8 d8d2c4f32d docs(memory,flow): cross-ref shared .backup/ namespace + drop stale prax/.backupignore
Completes the backup-docs sweep (td-218):
- @memory README: note rollover writes rollover_backup_*.json to <branch>/.backup/
- @flow README: note closed plans archive to <repo-root>/.backup/processed_plans/
  both cross-referencing @backup's canonical README.
- Removed orphaned src/aipass/prax/.backupignore (prax is not a registered
  backup target; only the AIPass project root is).

seedgo green across all three (@flow 100, @memory 100, @prax 99 = pre-existing
Json_Handler, unrelated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 07:00:36 -07:00
AIOSAIandClaude Opus 4.8 40602702ef docs(backup): correct backup/.backup/.backupignore docs across the system
Streamlined prompts had drifted from reality. Full-context investigation
(3 agents + @memory storyline) corrected:

- .backup/ documented as a SHARED runtime namespace (3 writers: @backup
  snapshot stores, @memory rollover safety copies, @flow processed_plans),
  not @backup-exclusive.
- @backup README: full 11-command coverage, .backup/ store layout, and a
  .backupignore (gitignore-for-backups: pathspec/gitwildmatch, BUILTIN_IGNORES,
  self-exclusion, ships as config) section.
- @backup branch prompt: stale .backup_system/ -> .backup/ (3x), drive_test.py
  -> drive_check.py (was misleading the agent every turn).
- Root README: @backup added to roster + uninstall covers .backup/.backupignore.
  navmap @backup line corrected (Drive planned + shared namespace).
- Shipped root /.backupignore realigned to BUILTIN_IGNORES (dropped stale
  .backup_system/, removed over-broad *logs).
- Removed dead backup/run/ test dir.

@backup verified: 220 tests green, seedgo 100%. Closes td-218.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 06:51:18 -07:00
AIOSAIandClaude Opus 4.8 c771a22771 chore(trigger): retire dead bulletin_created dashboard writer
The bulletin_created event handler propagated a 'bulletin_board' section into
every branch dashboard, but it was fully dead: nothing fired the event, its
BULLETINS.central.json store no longer exists, and prax already prunes
'bulletin_board' via DEPRECATED_SECTIONS. Archived the handler to
events/.archive/, removed its import + trigger.on() registration, dropped the
5 covering tests (558 pass). seedgo audit 100%. prax pruning left intact.
Closes td-102.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 06:12:31 -07:00
AIOSAIandClaude Opus 4.8 feecd263eb fix(seedgo): name-scope unused_function bypasses (kill silent line-drift)
unused_function bypasses matched by file+line; the line was the function's
def line, so any code shift above it staled the bypass and silently re-flagged
the exempted function, dropping the branch below 100% (S216/S217).

Mechanism: is_bypassed() gains a 'functions' field + name param; name-scoped
match takes precedence, 'lines' kept for back-compat (no other standard
changes). unused_function_check passes the function name. +7 tests (1093),
seedgo self-audit 100%, bypass schema documented.

Migration: converted 10 line-scoped entries to functions: across
drone/memory/skills; removed 3 dead memory/vector_search entries already
pointing past EOF (file is 152 lines). drone/memory/skills re-audit:
Unused_Function 100% (names verified live). Closes td-009.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 05:51:54 -07:00
AIOSAIandClaude Opus 4.8 03c95e6881 fix(seedgo): readme_check honors (disabled) marker in module/test self-scans
readme_check did its own modules/ and tests/ globs that bypassed the
central audit collector, so an in-place foo(disabled).py tripped a false
'missing module' violation and inflated README test counts. Wire
is_disabled_file into both globs (check_module_list, _count_test_functions).
+2 regression tests, 1086 green, self-audit 100%. Closes td-103.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEQZXCtgnF3NQtcttTErpq
2026-06-24 05:21:02 -07:00
301 changed files with 15192 additions and 7656 deletions
+10
View File
@@ -3,6 +3,11 @@
"hooks_enabled": true,
"UserPromptSubmit": {
"presence_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle",
"matcher": ""
},
"identity_injector": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.prompt.identity.handle",
@@ -98,6 +103,11 @@
"handler": "aipass.hooks.apps.handlers.notification.telegram_response.handle",
"matcher": "",
"timeout": 30
},
"presence_release": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.presence_gate.handle_stop",
"matcher": ""
}
},
+2 -1
View File
@@ -55,7 +55,7 @@ src/aipass/<name>/
- @skills — capability framework. Discoverable, self-contained skill units any agent can run; consume AIPass services as opt-in imports (e.g. the Telegram skill).
- @daemon — task scheduler. Cron-triggered firing; each branch owns its `.daemon/schedule.json`, the daemon discovers and fires.
- @commons — the social space. Where branches post, comment, vote, and gather as a community.
- @backup — local-first backups. Project-owned snapshots and restore for any directory; no external service.
- @backup — local-first backups. Snapshots + versioning + restore for any directory; optional Google Drive sync (planned). `.backup/` is a shared runtime namespace — @memory rollover and @flow (plan archive) also write there.
# Daily commands
@@ -96,6 +96,7 @@ Your continuity across sessions. Save proactively — after milestones, decision
- `local.json` — session log, key learnings, todos.
- `observations.json` — what you learn about the user.
- Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold.
- Entry caps are hook-enforced (over-limit edit = rejected whole). The live cap is rendered in each file's `*_meta` line — read it before writing, draft to ~80% of it; if rejected, rewrite hard in one pass.
# House rules
-2
View File
@@ -2,7 +2,6 @@
# Lines starting with # are comments. Blank lines are ignored.
# Edit this file to customize. Source defaults: handlers/ignore/patterns.py
.backup_system/
.backup/
.git/
.svn/
@@ -27,4 +26,3 @@ dist/
*.log
.ruff_cache/
.coverage
*logs
+13 -1
View File
@@ -25,7 +25,18 @@ Each memory file plays a distinct role. Update based on what actually changed th
- **`date`** — ISO date/datetime.
- Plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** sessions/key_learnings/observations — rollover archives the oldest *by number* to @memory automatically. **Todos are the exception** — rollover never touches them, so you prune done ones by hand (see Reconcile below).
### Reconcile todos — verify against reality, don't trust the label
Stored status drifts: a todo finished in a past session often never gets closed. Before writing the session entry, **audit every open todo against the actual system** — check the real state, not the stored `status`:
- Does the file/dir still exist (or is it gone)? Is the code path in or out? Does the README/doc actually say what the todo claims? Does the audit pass?
- **Close what's verifiably done** → note it in the session entry, then **DELETE the todo from the array**. Rollover never trims todos (they're operational — only sessions/key_learnings/observations roll), so done items left as `status: done` pile up and go stale across chats. Fail honestly — remove only on evidence, never just to tidy the list.
- **Re-scope what's partially done** → record which sub-items landed, keep the rest open.
- **Leave deferred / pending-decision todos open** — but confirm they're still real.
Quick checks beat assumptions: `ls`/`find` for files, `git ls-files`/`grep` for code/docs, `drone @seedgo audit` for standards. This step is the whole point of "close whats done."
## 2. Active Plans
@@ -55,6 +66,7 @@ List everything updated. Format:
```
Prep complete:
- local.json: [what was added]
- Todos: [reconciled vs reality — N done & removed, M re-scoped, K still open]
- observations.json: [updated / skipped]
- Plans: [which ones updated]
- Git: [branch, uncommitted count, suggestion]
+1 -1
View File
@@ -14,7 +14,7 @@ Purpose: Update branch memory files after completing work this session.
Each memory file plays a distinct role. Update based on what actually changed this session.
- **`.trinity/passport.json`** — IDENTITY. Who you are: role, capabilities, principles. Only update if identity genuinely evolved this session. Don't touch it just to touch it.
- **`.trinity/local.json`** — YOUR MEMORY. Session history, key_learnings, and todos[]. Add a session entry for significant work. Add key_learnings for facts you'd need next time. Update todos[] with open items. Trim oldest sessions if over 20.
- **`.trinity/local.json`** — YOUR MEMORY. Add a session entry for significant work; add key_learnings for facts you'd need next time. **Todos: add what you parked, and DELETE every todo you finished this session** — the proof goes in the session entry, not the todo. Rollover never trims todos (they're operational), so done ones you leave behind resurface as "open" next load and you waste time re-confirming them. (Sessions/key_learnings DO auto-roll by number — don't hand-trim those.)
- **`.trinity/observations.json`** — YOUR MEMORY OF THE USER. Collaboration insights, preferences, friction points, flow states. Skip entirely if nothing new about the user this session.
## If Relevant
+2 -2
View File
@@ -18,14 +18,14 @@ Purpose: Update branch memory files after completing work this session.
### Always
- **.trinity/local.json** — Add new session entry to `sessions` if significant work was done. Add new `key_learnings` for facts you'd need next time.
- **.trinity/local.json** — Add a session entry to `sessions` if significant work was done; add `key_learnings` for facts you'd need next time. **Todos: add what you parked, and DELETE every todo you finished this session** — the proof goes in the session entry, not the todo. Rollover never trims todos (they're operational), so done ones you leave behind resurface as "open" next load and you waste time re-confirming them.
- **.trinity/observations.json** — Add notable collaboration insights: breakthrough moments, pattern corrections, flow states, friction points, preference discoveries. Skip if nothing notable this session.
### Entry shape — one rule for all four types
`key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) all share ONE shape: a **list of objects, newest at the top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`.
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** — rollover archives the oldest *by number* to @memory automatically.
**When adding:** stamp `number` + `date`, then **prepend** (newest on top). **Don't hand-trim** sessions/key_learnings/observations — rollover archives the oldest *by number* to @memory automatically. **Todos are the exception** — rollover never touches them, so you prune done ones by hand (delete finished todos, see above).
### If Relevant
+4 -1
View File
@@ -18,7 +18,9 @@ Purpose: Button up everything at the end of a session — or before a /compact.
- **.trinity/observations.json** — Add collaboration insights if anything notable happened. Skip if nothing new.
- **.trinity/passport.json** — Only update if role/purpose/principles genuinely changed this session.
**Entry shape — one rule for all four types:** `key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) are all **lists, newest at top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`. Stamp `number` + `date` and **prepend**; **don't hand-trim** — rollover archives the oldest *by number* automatically.
**Entry shape — one rule for all four types:** `key_learnings`, `sessions`, `todos` (local.json) and `observations` (observations.json) are all **lists, newest at top (index 0)**. Every entry carries a **`number`** (monotonic int per type — highest = newest, never reused; new = current max + 1) and a **`date`** (ISO), plus its text field + extras: key_learnings `{number, date, key, value}` · sessions `{number, date, summary, status, tags}` · todos `{number, date, task, priority, status}` · observations `{number, date, note, tags}`. Stamp `number` + `date` and **prepend**; **don't hand-trim** sessions/key_learnings/observations — rollover archives the oldest *by number* automatically. **Todos are the exception** — rollover never touches them, so you prune done ones by hand (see Reconcile).
**Reconcile todos — verify against reality, don't trust the label.** Stored status drifts (a todo finished a past session often never got closed). Audit every **open** todo against the actual system: file/dir still there? code path in or out? README says what it claims? audit passes? **Close what's verifiably done** → note it in the session entry, then **DELETE the todo from the array** (rollover never trims todos — they're operational — so done items left as `status: done` pile up and go stale across chats), **re-scope** partials, **leave** deferred/pending-decision ones open. Fail honestly — remove only on evidence, never to tidy the list. Use `ls`/`find`/`git ls-files`/`grep`/`drone @seedgo audit`, not assumptions.
## 2. Active Plans
@@ -48,6 +50,7 @@ List everything updated. Format:
```
Prep complete:
- local.json: [what was added]
- Todos: [reconciled vs reality — N done & removed, M re-scoped, K still open]
- observations.json: [updated / skipped]
- Plans: [which ones updated]
- Git: [branch, uncommitted count, suggestion]
+4 -4
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- run: pip install ruff
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ matrix.python-version }}
- run: |
@@ -53,7 +53,7 @@ jobs:
# makes every file look born at HEAD, so every README false-fails as
# "stale". Full history makes CI match a local audit exactly.
fetch-depth: 0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- run: |
@@ -75,7 +75,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- run: |
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ matrix.python-version }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- run: pip install build
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
# Upgrade pip first: pip-audit scans the whole environment, and the
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.12'
+23 -23
View File
@@ -87,29 +87,29 @@ src/aipass/*/apps/integrations/**
!src/aipass/*/apps/integrations/README.md
# Spawn template exceptions (template files must be tracked for public repo)
!src/aipass/spawn/templates/builder/.trinity/
!src/aipass/spawn/templates/builder/.trinity/**
!src/aipass/spawn/templates/builder/.ai_mail.local/
!src/aipass/spawn/templates/builder/.ai_mail.local/**
!src/aipass/spawn/templates/builder/.archive/
!src/aipass/spawn/templates/builder/.archive/**
!src/aipass/spawn/templates/builder/.spawn/
!src/aipass/spawn/templates/builder/.spawn/**
!src/aipass/spawn/templates/builder/.claude/
!src/aipass/spawn/templates/builder/.claude/**
!src/aipass/spawn/templates/builder/*_json/
!src/aipass/spawn/templates/builder/*_json/**
!src/aipass/spawn/templates/builder/logs/
!src/aipass/spawn/templates/builder/logs/**
!src/aipass/spawn/templates/builder/artifacts/
!src/aipass/spawn/templates/builder/artifacts/**
!src/aipass/spawn/templates/builder/dropbox/
!src/aipass/spawn/templates/builder/dropbox/**
!src/aipass/spawn/templates/builder/tools/
!src/aipass/spawn/templates/builder/tools/**
!src/aipass/spawn/templates/builder/docs.local/
!src/aipass/spawn/templates/builder/docs.local/**
!src/aipass/spawn/templates/builder/DASHBOARD.local.json
!src/aipass/spawn/templates/aipass_framework/.trinity/
!src/aipass/spawn/templates/aipass_framework/.trinity/**
!src/aipass/spawn/templates/aipass_framework/.ai_mail.local/
!src/aipass/spawn/templates/aipass_framework/.ai_mail.local/**
!src/aipass/spawn/templates/aipass_framework/.archive/
!src/aipass/spawn/templates/aipass_framework/.archive/**
!src/aipass/spawn/templates/aipass_framework/.spawn/
!src/aipass/spawn/templates/aipass_framework/.spawn/**
!src/aipass/spawn/templates/aipass_framework/.claude/
!src/aipass/spawn/templates/aipass_framework/.claude/**
!src/aipass/spawn/templates/aipass_framework/*_json/
!src/aipass/spawn/templates/aipass_framework/*_json/**
!src/aipass/spawn/templates/aipass_framework/logs/
!src/aipass/spawn/templates/aipass_framework/logs/**
!src/aipass/spawn/templates/aipass_framework/artifacts/
!src/aipass/spawn/templates/aipass_framework/artifacts/**
!src/aipass/spawn/templates/aipass_framework/dropbox/
!src/aipass/spawn/templates/aipass_framework/dropbox/**
!src/aipass/spawn/templates/aipass_framework/tools/
!src/aipass/spawn/templates/aipass_framework/tools/**
!src/aipass/spawn/templates/aipass_framework/docs.local/
!src/aipass/spawn/templates/aipass_framework/docs.local/**
!src/aipass/spawn/templates/aipass_framework/DASHBOARD.local.json
# Commons artifacts subsystem — real source code (craft/trade/capsule), NOT a
# runtime dir. Collides with the blanket `artifacts/` ignore (line 49); *.py-only
+445
View File
@@ -9,6 +9,451 @@ PyPI version — not the changelog header.
---
## [2026-07-02]
Released as **2.6.1**. Rolls up the DPLAN-0226 / FPLAN-0289 / TDPLAN-0010 /
FPLAN-0298 batch (unified Telegram↔Claude Code bridge, single-session presence
gate, live Telegram streaming, `aipass init` template selector + portability,
`@backup share`) — all documented under `[2026-07-01]` — plus the CI
stabilization below.
### Fixed
- **CI green — six regressions from the DPLAN-0226 / FPLAN-0289 / TDPLAN-0010
batch (PR #646).** The dev branch had gone red across `seedgo-audit`, the
`test` matrix, and Windows; root-caused and fixed at source:
- **seedgo** — the new `template_check` advisory checker was gating CI.
`branch_audit.py` averaged *all* checker scores into the branch total, so
`template_check`'s `ADVISORY=True` was never honored and it dragged 7
branches below the 100% floor on legitimate README brace-examples. Added a
`gating_scores` filter that excludes `ADVISORY is True` checkers before
computing the average (strict `is True` to avoid MagicMock false-positives)
and exposed `advisory_standards` in the audit output. Also refreshed the
provider hooks snapshot fixture to include the `presence_gate`
`UserPromptSubmit` hook (FPLAN-0289), fixing 4 `test_hooks_snapshot` tests.
- **hooks** — `cc_sessions.py` (added by the bridge, `f6cbe34`) was missing
its README entry and a seedgo `modules` bypass (it reads external
`~/.claude/sessions/*.json`, not branch data, so `json_handler` is the wrong
tool — same precedent as `presence.py`). Added both.
- **spawn** — retired the `passport(disabled).py` / `passport_ops(disabled).py`
pair to `.archive/`; the `(disabled)` suffix kept them visible to the type
checker, which flagged a broken cross-import between them.
- **ai_mail** — `test_child_inherits_broker_fd` gave its throwaway test branch
a real `.trinity/passport.json` so the broker's new `.trinity`-marker
resolution (`f914ab6`) can resolve it and permit the delete.
- **spawn** — the `builder→aipass_framework` template rename (`13463c0`) left
`.gitignore` exceptions pointing at the old `templates/builder/` path, so
`DASHBOARD.local.json` + ~10 other template files were silently untracked
since the rename — present on disk (dirty tree passed) but absent from clean
clones/CI, so `test_full_spawn` failed only in a clean checkout. Fixed all 23
`.gitignore` exception paths and committed the now-visible template
scaffolding.
- **skills** — `test_streaming` asserted a `+1` newline byte, but `write_text`
text mode translates `\n`→`\r\n` on Windows (2 bytes), failing `windows-setup`
only. Switched the test's transcript writes to `write_bytes()` for
deterministic LF; production `_tail_transcript_bytes` was already CRLF-safe.
## [2026-07-01]
### Added
- **`aipass init` is now a template selector (TDPLAN-0010)** — `init` presents a
chooser with **`empty project`** at the top, pre-selected as the default
(creates just the project folder, no scaffold), and **`aipass_framework`**
below it (the full AIPass agent framework — the old always-on behavior, now
opt-in). Flag and positional forms both work: `aipass init --list` (branches
before the `--` catch-all) and `aipass init <template>`. The AIPass-specific
stages (8 spawn-first-agent / 9 ping-registry / 11 handoff / 12 init_report,
`AIPASS_SPECIFIC_STAGES`) and the `bootstrap.init_project()` scaffold are now
gated on the chosen template, so an empty project stays empty. In-product pip
hints in `init_flow.py` + `doctor.py` retuned to clone/`setup.sh`. 8 new
selector tests; 499 tests pass. (built by @aipass, FPLAN-0295, TDPLAN-0010)
- **Unified Telegram ↔ Claude Code bridge — CC-native session discovery
(DPLAN-0226)** — a Telegram message to a branch's bot now lands directly in
that branch's live Claude Code session, and the reply tails back out to
Telegram — a full round trip, **live-proven end-to-end from Patrick's own
Telegram client** (not just a self-test). The bot's inbound path
(`base_bot.ensure_tmux_session`) discovers the active session by enumerating
CC-native `~/.claude/sessions/<pid>.json` files (match `cwd`, confirm PID
alive, newest by `startedAt`), maps it to a tmux pane by cwd, and injects the
message — replacing the old `PRESENCE.central.json` pointer, which is kept but
commented out. The outbound path gains a CC-native "Strategy 0" in
`_resolve_active_transcript` that prefers the discovered transcript, so
assistant replies relay back reliably. Anthropic ToS rules out a cloud peer,
so all delivery is local (tmux/PTY). New `session_boot.py` boot wrapper
(attach-if-live-else-start-in-tmux; a thin `~/.bashrc claude()` shim delegates
to it). Hooks tests 66 green (presence_gate / cc_sessions / session_boot),
telegram presence_pointer 42 green. (DPLAN-0226 P1/P2, FPLAN-0290/0291/0292)
- **Seedgo stale-template audit checker (`template_check`)** — a new advisory
standard that flags branches still carrying unrendered template markers in
their local prompts / config, so a citizen that never customized its scaffold
no longer fails silently. Auto-discovered like every other checker; advisory
(warns, never blocks). Ships with `template_content.py` and a `template.md`
standard doc, covered by `test_template_check.py`. (built by @seedgo, DPLAN-0228)
### Fixed
- **Drone `--json` output no longer corrupts machine JSON** — `--json`
pass-through was routed through Rich's `console.print()`, which defaults to
width 80 on a non-TTY and hard-wraps mid-string, producing invalid JSON
(e.g. `"Security \nScan"`). Fixed by writing raw JSON with `sys.stdout.write()`
in the pass-through paths (`drone.py` + `router.py`) while keeping Rich for
drone's own human UI. Verified live end-to-end. (fixed by @drone, td-49)
### Changed
- **README: pip removed, clone-only install (TDPLAN-0010)** — the top-level
README no longer documents `pip install aipass` anywhere: the PyPI badge, the
install steps (hero + Quick Start), the Project Status version badge, and the
uninstall `pip uninstall` line are all removed. Install is now a single path —
`git clone … && ./setup.sh` (puts `aipass` + `drone` on PATH), then
`aipass init` scaffolds agents into your own project on top. Quick Start
reorganized into Install → Your own project → Explore the full framework.
(packaging code untouched; docs are clone-first.) (DPLAN-0228, devpulse)
- **Spawn: `builder` template → `aipass_framework`, birthright retired,
per-project registry targeting (TDPLAN-0010)** — the citizen_class/template
`builder` is renamed to **`aipass_framework`** across `class_registry.py`,
`core.py`, `meta_ops.py`, `update_ops.py`, `sync_registry_ops.py`, help text,
and the template dir itself (`templates/builder/` → `templates/aipass_framework/`).
The class is no longer baked as a literal in the template passport — a new
**`{{CITIZEN_CLASS}}` placeholder** (passport line 21, `placeholders.py`) now
takes it from the create call. **`birthright`** (0 live users) is retired to
`templates/.archive/birthright/` and its `passport` command disabled
(`passport.py` / `passport_ops.py` → `(disabled).py`, routing removed).
**Per-project registry targeting:** `spawn`'s `find_registry()` no longer
passes `package_root` to the shared discovery (killing the silent fallback to
AIPass's own registry for external targets), and `_spawn_agent` now validates
containment and, if the found registry is outside the target's project, walks
up from the target for `.git`/`pyproject.toml`/`setup.py`/`setup.cfg` to use
**that project's own registry** — so an agent created into any project is
tracked by that project's registry, never AIPass's. The
`_validate_path_containment` isolation invariant is untouched. `create` also
degrades gracefully when `@memory` is unavailable (empty meta-tabs, no crash).
297 tests pass. (built by @spawn, FPLAN-0294, TDPLAN-0010)
- **Drone resolution + access checks made project-portable (TDPLAN-0010
foundation)** — five `src/aipass`/fixed-depth self-location hardcodes are
replaced with `.trinity/`-marker walk-ups: `rm_handler` sibling protection,
`commit_handler` test-gate branch detection, `broker/daemon` allowed-bases,
the `handlers/__init__` import-guard access check (now `is_relative_to()`
instead of scanning path parts for the literal `aipass`), and
`registry_handler`'s `parents[4]` last-resort (now a
`.git`/`pyproject.toml`/`setup.py`/`setup.cfg` marker walk). `@name`→path
resolution now works for an agent in any project layout via a CWD-first
registry walk (AIPASS_HOME only as a last resort when the CWD ancestry has no
registry at all). The `_validate_branch_path` containment invariant is
untouched — per-project isolation preserved. (Drone uses its own resolver, not
the shared `registry_discovery.py`.) 838 tests pass. (built by @drone,
FPLAN-0296, TDPLAN-0010)
- **ai_mail routing made project-portable (TDPLAN-0010 foundation)** — the
fixed-depth `_REPO_ROOT = parents[2].parents[2]` self-location in
`email.py` / `email_send.py` / `dispatch.py` (4 sites) is replaced with the
portable `find_repo_root()` marker-walk already used in
`delivery.py` / `wake.py` / `paths.py`, so mail resolves via the project
marker instead of a hardcoded tree depth — a prerequisite for agents that
live outside `src/aipass/`. Per-project isolation preserved (no cross-project
mailbox routing). 737 tests + seedgo 100%. (built by @ai_mail, FPLAN-0293,
TDPLAN-0010)
- **Presence gate re-sourced to CC-native session files (presence_gate v2)** —
the single-session guard now sources truth from `~/.claude/sessions/<pid>.json`
via a new `cc_sessions` module (`find_occupant`/`find_live_for_cwd`) instead of
`PRESENCE.central.json`. Resume-aware (a `/resume` keeps the same PID, so the
session is correctly recognized as re-entry, not a duplicate) and exit-aware
(CC deletes the file on clean exit). `handle_stop` is now a plain no-op —
cleanup is CC's job. The old `presence.py` / `PRESENCE.central.json` are
preserved, just no longer sourced. (DPLAN-0226 P1)
## [2026-06-25]
### Added
- **Daemon auto-runner — systemd user timer (the deferred last mile of the
decentralized scheduler)** — `.daemon/schedule.json` jobs now fire **hands-off**.
A oneshot `daemon-tick.service` + `daemon-tick.timer` (every ~2 min, mirroring
the `prax-monitor.service` pattern: user-scope `~/.config/systemd/user/`, `%h`
not hardcoded paths, venv-python ExecStart `-m aipass.daemon.apps.daemon run`,
logs to `~/.aipass/daemon-tick.log` outside any tailed dir) reuses the existing
fcntl-locked `run.py` tick unchanged — the timer is the ticker. New
`apps/modules/timer_install.py` installs/enables it idempotently. Live-proven:
@devpulse received a `DAEMON TEST` ping from a branch woken purely by the timer,
no human tick. Tick profile: ~1.7s (import overhead only); the earlier CPU spike
was `wake_branch` spawning opus agents concurrently, **not** the tick — so
scheduled wakes want light models + staggering. Closes the piece DPLAN-0204 /
FPLAN-0282 deferred. 461 daemon tests green, seedgo 100%. (FPLAN-0287)
- **Prax monitor → Telegram relay (`prax_monitor` bot)** — the live
`drone @prax monitor run` Mission-Control feed now mirrors to a dedicated
Telegram bot, so the whole-system monitor is watchable from a phone ("same
monitor, different window"). New `monitoring/telegram_relay.py` taps the single
render seam (`_render_event`), buffers events, and flushes every 5s (4000-char
split, 150-line flood cap, `disable_notification`); fail-silent-once when
unconfigured. Gated behind `--relay` / `AIPASS_PRAX_MONITOR_RELAY=1` so a local
`monitor run` stays console-only (no double-send). Bot config (token + chat_id)
loads from the @api secret `telegram/prax_monitor`. Ships a reboot-survivable
`prax-monitor.service` user unit. 937 prax tests green (31 new). (DPLAN-0221)
- **Self-documenting `.trinity` state-tabs** — each memory-file section
(`todos` / `key_learnings` / `sessions` / `observations`) now carries a
config-sourced `⟦ rollover ON/OFF · keep N · ≤chars ⟧` tab rendered directly
above it, so an agent editing a section sees its rollover state and character
cap at the edit point (stops over-limit writes). Values are generated from
`memory.config.json` (single source of truth) via @memory's new
`render_all_meta_tabs()` / `tab_renderer.py`; @memory's `spawn_pusher` carries
the `{{*_META}}` placeholders into @spawn's branch templates, and @spawn
resolves them at create (`build_replacements_dict`, fail-loud on missing keys)
so new branches auto-populate. `refresh_all_tabs` keeps live branches synced;
@memory README documents the system. (FPLAN-0285, FPLAN-0286)
### Changed
- **Todo management — delete-on-done discipline** — `todos[]` are operational
and exempt from rollover (confirmed; the vestigial `todos` entry was removed
from `memory.config.json` rollover defaults). Because rollover never trims
them, finished todos must be **deleted**, not left as `status: done` (which
pile up and resurface as "open" across sessions). `/prep` and `/memo` (Claude
+ Codex) and the `CLAUDE.md` startup protocol now codify: delete each todo when
done (proof → session entry), reconcile on load. (FPLAN-0285)
### Fixed
- **Daemonized wakes killed by systemd cgroup teardown (td-48)** — timer-fired
`wake_branch()` calls spawned the dispatch monitor + claude child, then died
within seconds with no email and a stale lock, while the *same* wake from an
interactive terminal worked. Root cause: a systemd oneshot service defaults to
`KillMode=control-group`, so when the ~1.7s tick process exits, systemd SIGTERMs
**every member of its cgroup** — `start_new_session=True` is irrelevant because
systemd tracks by cgroup, not process group. Fix in `ai_mail` dispatch: detect
the systemd context (`INVOCATION_ID`) and re-spawn the monitor via
`systemd-run --user` in its **own transient unit**, escaping the parent cgroup
(falls back to direct `Popen` when not under systemd); plus `stdin=DEVNULL` on
both the monitor and claude `Popen` calls and monitor PID self-registration in
the lock. Now genuinely live-proven through the timer: 3 branches
(commons/cli/backup) woken purely by `daemon-tick.timer` each emailed @devpulse
and exited clean (~20s, code=0). 737 ai_mail tests green, seedgo 100%.
- **seedgo-audit — telegram ported-but-unwired functions** — the DPLAN-0218
relocation pulled the telegram lib into the seedgo gate's scope, surfacing 16
`unused_function` flags across 8 handler files. These are *not* dead code —
they're ported-but-unwired from the ~9k-line Dev-Pass port (S249), awaiting
DPLAN-0220 wiring (on_response hooks, response_router, tmux session mgmt, file
up/download, multi-bot, config helpers). Added name-scoped `unused_function`
bypasses in `skills/.seedgo/bypass.json` (the existing mechanism), each citing
DPLAN-0220, and documented every one in `SKILL.md` → *Ported-but-unwired* with
a "remove the bypass as you wire each fn" note. @skills back to 100%.
- **seedgo-audit — @spawn direct JSON read** — `core.py` adopt-path read a
passport via `json.loads(path.read_text())` (direct file op), failing the
`json_handler` standard and the CI seedgo-audit gate. Switched to
`json_handler.read_json()` (the same pattern used a few lines above), dropping
the now-unused `import json as _json`. @spawn back to 100%; 315 spawn tests
green.
- **Windows CI — telegram `bot_registry` crashed test collection** — the module
did a bare `import fcntl` (POSIX-only), so on Windows all 8 telegram test
modules that transitively import it failed at *collection* with
`ModuleNotFoundError: No module named 'fcntl'`, reddening Windows Test on the
last several PRs. Guarded the import (`try/except ImportError → fcntl = None`,
the established hooks/daemon convention) and routed the three flock call-sites
through no-op-on-Windows `_lock`/`_unlock` helpers — advisory locking still
applies on POSIX, is skipped where unavailable. Fixing collection then
*unmasked* three telegram tests that had never actually run on Windows, all
test-portability bugs (not product bugs): a log-streamer byte-count broke on
CRLF translation (fixture now writes `newline=""`); a registry write-failure
test used the Unix-only `/proc` path (now a cross-platform file-as-directory
parent); and `validate_bot_config` rejected valid POSIX `work_dir`s on Windows
because `Path.is_absolute()` is host-dependent (now tests POSIX *and* Windows
absoluteness). 493 telegram tests green.
- **prax-monitor service feedback loop** — the unit wrote its own stdout into
`system_logs/`, the very directory the monitor tails *and* @trigger watches,
creating a self-reinforcing loop (monitor output → re-tailed and recorded by
@trigger into `trigger_data.json` → reported as a file change → more output).
Moved the service log to `~/.aipass/` to break the cycle. Also corrected the
ExecStart to `monitor run` (relay enabled via env) — the module `__main__`
rejects the drone-style `run all --relay` argument form. (DPLAN-0221)
## [2026-06-24]
### Changed
- **Skill library relocated to `src/aipass/skills/lib/`** — first-party skills
were split across `catalog/` (built-in, cross-branch) and `.aipass/skills/`
(the branch-prompt dir, cwd-relative). Renamed `catalog/`→`lib/`, moved the
telegram skill in, archived three orphan test-fixture skills, and retired
`.aipass/skills/` from the branch. This unifies all 6 first-party skills under
one built-in tier and **fixes the telegram skill not being discoverable from
other branches** (it sat in a cwd-relative path). The public discovery
convention (`.aipass/skills/` + `~/.aipass/skills/`) is unchanged. One
functional line changed (`discovery_handler` built-in path); telegram's test
`conftest` path-depth, the systemd `.service` ExecStart, and seedgo bypass +
test paths were updated to match. Packaging, imports, and gitignore are
unaffected (everything stays under `src/aipass/`). 252/252 skills tests green;
cross-branch discovery verified from another branch. Moving telegram into the
gate's scope newly surfaced 9 pre-existing `unused_function` flags in its
handlers — triage tracked separately. (DPLAN-0218)
### Added
- **`@api` in-process `set_secret` write-door** — `aipass.api.apps.modules.secrets.set_secret(provider, slug, value, *, as_json=False)`
mirrors the existing `get_secret`, writing `~/.secrets/aipass/<provider>/<slug>.json`
(dirs `0o700`, files `0o600`, value never echoed to stdout or logged). The @api
secrets store was previously read-only; this is the writer the telegram
mother-bot needs to persist a newly-created bot's config so the child can read
its token. 515 @api tests pass (11 new), @api seedgo 100%. (DPLAN-0220)
- **Prax-monitor v1 on Telegram — `/monitor` system-wide log subscription** —
the old Dev-Pass "prax monitor bot" (a `@prax` push relay on a dedicated token)
was stripped during the port; this revives the capability as a feature of the
existing `@aipass` bot (no second bot, no new credential). New `/monitor on`
(errors+warnings) / `all` (firehose) / `off` / `status` command on `base_bot`,
shown in the slash menu + `/help`. The subscribed chat is persisted to the `@api`
store (`set_secret('telegram','monitor',{chat_id,mode})`) so it survives restart,
and `base_bot` boot-starts the stream from it on startup — set-and-forget under
systemd. `LogStreamer` gained `system_wide` (glob all `system_logs/*.log`, not one
branch) + `level_filter` (default keeps `WARNING`/`ERROR`/`CRITICAL`, `all` =
passthrough); `_init_positions` still seeks EOF so subscribing never floods
history. 33 new tests (`test_monitor.py`), telegram suite 493/493, skills 252/252,
@skills seedgo 98%. (First @skills run crashed mid-edit on 3 string-handling
syntax errors; continued + fixed.) The richer AS-WAS `@prax` event-feed relay
(rendered Mission-Control stream, needs a dedicated-bot-token decision) is tracked
as Route B. (DPLAN-0221)
### Fixed
- **Telegram port — wave 1 (persistence + monitor + state hygiene)**, surfaced by
a full completeness audit against `TELEGRAM_PORT_MAP.md` (366 tags, ~83% ported,
452/452 tests green): (1) **bot launch** — `bot_factory.start_bot_process` and
`telegram-bot@.service` used a non-existent `~/.venv/bin/python3`; now launch via
`sys.executable -m …base_bot` (added `lib/__init__.py` + `lib/telegram/__init__.py`
for package resolution, since base_bot uses relative imports). (2) **reboot
survival** — `enable_service` now installs the systemd unit to
`~/.config/systemd/user/` + `daemon-reload` (previously the unit was never
installed, so `enable` silently no-op'd). (3) **state hygiene** — gitignored
`skills/.../lib/telegram/.local/` so the runtime registry/offset/lock files stop
leaking into the repo. (4) **prax-monitor** — `log_streamer` tailed a hardcoded
`~/system_logs` while prax writes to the repo-root `system_logs`; now resolves the
repo root (honoring `AIPASS_TEST_LOG_DIR`) so the log stream actually delivers.
(5) **auto-create (GAP1)** — `create_bot` wrote a new bot's config only to a disk
shadow file while the runtime loads its token exclusively from the @api store, so
a minted bot started then exited with no config; `create_bot` now calls
`set_secret('telegram', bot_id, config, as_json=True)` (fail-loud) so the
create→@api→load round-trip works and the mother-bot can mint startable bots. New
round-trip + fail-loud tests; telegram suite 454/454.
(6) **/help + Telegram command menu** — `setMyCommands` only ran inside
`create_bot`, so hand-launched bots (like the live `@aipass`) had no slash-menu,
and the menu list had drifted from `/help`; `base_bot` now sets its menu on
startup from a single source (`build_botfather_commands`, also used by
`create_bot` — `DEFAULT_BOT_COMMANDS` retired), so the Telegram menu and `/help`
list the same enriched commands incl. `/create`/`/cancel`. Wiring the builder
(rather than deleting it as "dead") also lifted Unused_Function 92→93%. 6 new
tests, telegram suite 460/460. (A running bot needs a restart to pick up the
startup menu.)
(DPLAN-0220)
- **Telegram `@aipass` deployed under systemd (reboot survival + clean lifecycle)** —
the live mother-bot was a hand-launched foreground process: no reboot survival, and
`stop_bot`/restart targeted an uninstalled `telegram-bot@base` unit, so there was no
working lifecycle command. Installed the user service + `enable --now` +
`loginctl enable-linger` (`Linger=yes`); the 17:26 startup log confirms the full
chain live — `Telegram API OK`, **`Command menu set (6 commands)`** (the new `/help`
menu), stale-lock cleanup, poll loop, tmux Claude session preserved, `NRestarts=0`.
Also corrected the ported unit's `StandardOutput`/`StandardError`, which pointed at a
non-existent `~/system_logs` (would have crash-looped the service) — now
`<repo>/system_logs`, matching where the app already logs. Restart is now
`systemctl --user restart telegram-bot@base`. (DPLAN-0220)
- **seedgo CLI help checkers green-lit non-compliant `--help` output** — the
`cli`/`help_text`/`introspection` standards are static source scans (they
confirm a `print_help` function, `console.print`, and `--help` wiring exist)
but never execute `--help`, so a module could score 100% while rendering raw
argparse. `@ai_mail` did exactly that via `console.print(parser.format_help())`,
laundering argparse's plain text through the approved console API and dodging
the existing `parser.print_help()` ban. Closed the loophole: `cli_check` now
flags `.format_help()`, `cli.md`/`cli_content.py` name it alongside
`print_help()`, +2 regression tests. Also rewrote `@ai_mail`'s `print_help()`
to render hand-rolled Rich (the `--help` content was complete, just unstyled).
A behavioral `--help` check (run it, assert not raw argparse) is noted as a
follow-up. (DPLAN-0217) On its first CI run the tightened checker immediately
surfaced the same pattern in 4 `@api` modules (`api_key`, `usage_tracker`,
`google_client`, `openrouter_client`) — migrated to Rich, `@api` back to 100%.
- **seedgo `readme_check` ignored the `(disabled)` marker in self-scans** — its
module-list and test-count scans now skip `foo(disabled).py`, matching the
central audit collector. An in-place disabled module no longer trips a false
"missing module" violation; disabled test files no longer inflate README test
counts (td-103).
- **seedgo `unused_function` bypasses are now name-scoped** — bypasses match by
function name (`functions: [...]`) instead of line number (`lines: [...]`),
which drifted silently when code shifted and re-flagged exempted functions
(bit us S216/S217). `lines` stays supported for other standards. Migrated the
10 existing line-scoped entries across drone/memory/skills and dropped 3 dead
entries already pointing past EOF (td-009).
- **Dispatch footer no longer tells workers to close the orchestrator's plan** —
the standard email footer's checklist item read `CLOSE FPLAN → drone @flow
close <plan_id>`, which led dispatched agents to close the master/parent plan
referenced in their brief (bit us in FPLAN-0260). Reworded to `CLOSE YOUR PLAN
→ ... this task's plan only, never the master/parent` — a worker still closes
the sub-plan handed to it, but the master stays the orchestrator's to close on
completion (td-6).
### Changed
- **Backup `.backupignore` default moved out of code into a template file** — the
seed content backup writes into a new project's `.backupignore` now lives in
`backup/templates/backupignore.template` (loaded at register), matching the
AIPass convention that templates are data files, not hardcoded Python. Retired
the `BUILTIN_IGNORES` list; `_build_backupignore()` reads the template and
**raises** if it's missing — never silently empty, since an empty
`.backupignore` would back up everything and crash. Docs/comments repointed to
the template (td-30).
### Removed
- **Dead `bulletin_created` trigger handler** — the event handler that wrote a
`bulletin_board` section into every branch dashboard is retired: nothing fired
the event, its `BULLETINS.central.json` store no longer exists, and prax
already prunes `bulletin_board` as a deprecated section. Archived + unwired
from the event registry; prax's pruning stays (td-102).
- **Dead `backup/run/` test dir** — leftover from an ad-hoc backup test run
(only its generated `.backupignore` had been tracked); removed (td-218).
### Documentation
- **Backup docs corrected** — `.backup/` is now documented as a **shared runtime
namespace** (@backup stores + @memory rollover safety copies + @flow plan
archive), not @backup-exclusive. @backup's README gained full command coverage,
the `.backup/` store layout, and a `.backupignore` ("gitignore for backups")
section; its branch prompt's stale `.backup_system/` / `drive_test.py` names
were fixed. Root README lists @backup and documents `.backupignore`; the navmap
was corrected. The shipped root `/.backupignore` was realigned to
`BUILTIN_IGNORES` (dropped stale `.backup_system/` + over-broad `*logs`).
@memory and @flow READMEs now cross-reference their `.backup/` writes, and the
orphaned `prax/.backupignore` (a stale per-branch config) was removed.
- **Root README agent roster brought current** — added the three missing agents
(`@daemon`, `@skills`, `@commons`) to the tree and tables, and normalized the
agent count to **17** everywhere (was an inconsistent mix of "13" and "14").
`@daemon` joins Quality & operations; a new "Capabilities and community" group
covers `@skills` + `@commons` (td-28).
- **`/prep` now reconciles todos against reality** — the session-wrap command
(both the Claude `.claude/commands/prep.md` and the Codex skill mirror) gained
a step to audit every open todo against the actual system (`ls`/`find`/`git
ls-files`/`grep`/`audit`) and close what's verifiably done — catching todos
finished in a past session but never closed.
- **Backup ignore architecture documented** — confirmed and written down the
two-layer model so it stops getting re-discovered: `BUILTIN_IGNORES` is the
**seed** that generates a new project's `.backupignore` at register and is
never consulted at backup time; `.backupignore` (via `load_spec`) is the
**runtime source of truth**. There's no static fallback, so the seed is
safety-critical — an empty `.backupignore` backs up everything and can crash
the machine. Added a "How Ignores Work" README section + code comments on
`BUILTIN_IGNORES` and `load_spec`. Also added `logs/` to the seed so new
projects exclude log directories (e.g. prax `.jsonl` output) by default, not
just `*.log` files (td-27).
## [2026-06-23]
The **2.6.0** release — a large `dev → main` merge spanning several weeks (68 commits).
+3 -1
View File
@@ -18,4 +18,6 @@ Use drone commands for all operations. Never raw git, gh, file access, or python
# Memories
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
Update `.trinity/` at natural breakpoints, after milestones, and on `/memo`.
Todos[] don't auto-roll — rollover never trims them. So **delete each todo the moment it's done** (never leave it as `status: done`), and **reconcile on load**: close/remove anything already finished so completed work never resurfaces as "open" and wastes a re-confirm.
+33 -18
View File
@@ -1,7 +1,6 @@
[![Status](https://img.shields.io/badge/status-beta-yellow)](#project-status)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![PyPI](https://img.shields.io/pypi/v/aipass)](https://pypi.org/project/aipass/)
[![Give Feedback](https://img.shields.io/badge/Give-Feedback-brightgreen)](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml)
[![codecov](https://codecov.io/gh/AIOSAI/AIPass/graph/badge.svg)](https://codecov.io/gh/AIOSAI/AIPass)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge)](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass)
@@ -31,8 +30,10 @@ That's not a team. That's a room full of people wearing headphones.
AIPass is a CLI-native scaffold that adds **persistent memory, identity, and coordination** to your AI agents. You bring your project — AIPass adds the agent layer on top. No UI, no dashboard. You work in your terminal.
```bash
pip install aipass
mkdir my-project && cd my-project
git clone https://github.com/AIOSAI/AIPass.git
cd AIPass && ./setup.sh # installs the `aipass` + `drone` commands on your PATH
cd ~ && mkdir my-project && cd my-project
aipass init run
```
@@ -78,12 +79,17 @@ aipass init agent my-agent # Full agent: apps, mail, memory, identity
## Quick Start
### Your own project
### 1. Install
```bash
pip install aipass
git clone https://github.com/AIOSAI/AIPass.git
cd AIPass && ./setup.sh # Creates venv, installs, puts `aipass` + `drone` on your PATH, bootstraps 17 agents
```
mkdir my-project && cd my-project
### 2. Your own project
```bash
cd ~ && mkdir my-project && cd my-project
aipass init run # Guided setup — project, first agent, terminal handoff
```
@@ -97,15 +103,11 @@ aipass doctor # Check system health
> **Need help?** [Ask in Discussions](https://github.com/AIOSAI/AIPass/discussions) or [file feedback](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml) — both take 30 seconds.
### Explore the full framework
### 3. Explore the full framework
Clone the repo to see all 13 agents working together — the reference implementation:
The clone above already includes all 17 agents working together — the reference implementation:
```bash
git clone https://github.com/AIOSAI/AIPass.git
cd AIPass
./setup.sh # Creates venv, installs, bootstraps 13 agents
cd src/aipass/devpulse
claude # Talk to the orchestrator
```
@@ -149,13 +151,13 @@ drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than
**Two ways to use AIPass:**
- **Your own project:** `aipass init run` sets up a new project with your first agent. Add more agents as you need them. Your first agent is the orchestrator — it coordinates the others.
- **The full framework:** Clone the repo to work with all 13 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
- **The full framework:** Clone the repo to work with all 17 core agents. Talk to `devpulse` (the orchestrator), dispatch work across specialists. Agents work in parallel and report back.
---
## The Reference Implementation
AIPass ships with 13 core agents that maintain and develop the framework itself — proving the architecture works at scale. You don't need any of these to use AIPass in your own project. They're here as examples and as services your project can call.
AIPass ships with 17 core agents that maintain and develop the framework itself — proving the architecture works at scale. You don't need any of these to use AIPass in your own project. They're here as examples and as services your project can call.
```
devpulse (orchestrator)
@@ -170,7 +172,11 @@ devpulse (orchestrator)
├── memory — automatic archival, ChromaDB, semantic search
├── api — LLM access layer (OpenRouter, multi-provider)
├── trigger — event-driven automation + self-healing
└── cli — terminal formatting and rich output
├── cli — terminal formatting and rich output
├── backup — local-first snapshots + restore (optional Drive sync)
├── daemon — cron-style task scheduler (each branch owns its schedule)
├── skills — discoverable capability units any agent can run
└── commons — the social space — post, comment, vote, gather
```
These agents work on the **same filesystem, same project, same time** — no sandboxes, no worktrees. This is the pattern your projects inherit.
@@ -201,6 +207,15 @@ These agents work on the **same filesystem, same project, same time** — no san
| [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch |
| [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing |
| [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output |
| [**backup**](src/aipass/backup/README.md) | Local-first backups — snapshots, versioning, restore (optional Google Drive sync) |
| [**daemon**](src/aipass/daemon/README.md) | Task scheduler — cron-style firing; each branch owns its schedule |
**Capabilities and community** — what agents can do and where they gather:
| Agent | Role |
|-------|------|
| [**skills**](src/aipass/skills/README.md) | Capability framework — discoverable, self-contained skill units any agent can run |
| [**commons**](src/aipass/commons/README.md) | The social space — agents post, comment, vote, and gather as a community |
</details>
@@ -225,7 +240,7 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each.
| Metric | Value |
|--------|-------|
| Version | [![PyPI](https://img.shields.io/pypi/v/aipass?label=)](https://pypi.org/project/aipass/) |
| Version | See [git tags](https://github.com/AIOSAI/AIPass/tags) |
| Agents | 13 core + user-created |
| Quality | Automated standards enforced across every agent |
| Coverage | [![codecov](https://codecov.io/gh/AIOSAI/AIPass/graph/badge.svg)](https://codecov.io/gh/AIOSAI/AIPass) — 75% minimum, CI-gated |
@@ -266,8 +281,8 @@ AIPass stores everything locally in your project directory. To remove it:
rm -rf .aipass/ .claude/ .ai_mail.local/ hooks/ src/
rm -f CLAUDE.md AGENTS.md *_REGISTRY.json .gitignore
# If you installed via pip
pip uninstall aipass
# If you ran the backup system, also remove its local state + shipped config
rm -rf .backup/ && rm -f .backupignore
```
No cloud accounts, no external services, no cleanup beyond your local filesystem.
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.6.0"
version = "2.6.1"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
@@ -51,6 +51,9 @@ memory = [
"chromadb>=1.0",
"fastembed>=0.4",
]
telegram = [
"telethon>=1.36",
]
seedgo = []
dev = [
"pytest>=9.0.3",
+2 -3
View File
@@ -1,7 +1,6 @@
"""AIPass — Multi-agent orchestration framework.
pip install aipass
https://github.com/AIOSAI/AIPass
git clone + ./setup.sh — https://github.com/AIOSAI/AIPass
"""
__version__ = "2.6.0"
__version__ = "2.6.1"
+37 -43
View File
@@ -16,7 +16,6 @@ Main handles routing, modules implement functionality.
# Standard library imports
import sys
import importlib
import argparse
import signal
from pathlib import Path
from typing import Any, List
@@ -51,52 +50,47 @@ MODULES_DIR = MODULE_ROOT / "modules"
def print_help():
"""Print drone-compliant help output"""
parser = argparse.ArgumentParser(
description="AI_MAIL Branch Operations - Email system for branch communication",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS:
dispatch - Send dispatch email + wake target (one step)
email - Send email to a branch
send - Send email (alias for email)
inbox - List emails (new + opened)
view - View email content (marks as opened)
reply - Reply to email (closes + archives)
close - Close email(s) without reply (archives)
sent - View sent messages
contacts - Manage contacts
EMAIL LIFECYCLE (v2):
new → opened → closed
- new: Just arrived, never viewed
- opened: You've viewed it, not yet resolved
- closed: Resolved (replied or dismissed), auto-archived
"""Print drone-compliant help output with Rich markup"""
console.print()
console.print("[bold cyan]AI_MAIL — Email system for branch communication[/bold cyan]")
console.print()
USAGE:
drone @ai_mail <command> [args]
drone @ai_mail --help
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" [cyan]dispatch[/cyan] [dim]Send dispatch email + wake target (one step)[/dim]")
console.print(" [cyan]email[/cyan] [dim]Send email to a branch[/dim]")
console.print(" [cyan]send[/cyan] [dim]Send email (alias for email)[/dim]")
console.print(" [cyan]inbox[/cyan] [dim]List emails (new + opened)[/dim]")
console.print(" [cyan]view[/cyan] [dim]View email content (marks as opened)[/dim]")
console.print(" [cyan]reply[/cyan] [dim]Reply to email (closes + archives)[/dim]")
console.print(" [cyan]close[/cyan] [dim]Close email(s) without reply (archives)[/dim]")
console.print(" [cyan]sent[/cyan] [dim]View sent messages[/dim]")
console.print(" [cyan]contacts[/cyan] [dim]Manage contacts[/dim]")
console.print()
EXAMPLES:
# Dispatch (send + wake in one command)
drone @ai_mail dispatch @branch "Subject" "Body"
drone @ai_mail dispatch @branch "Subject" "Body" --fresh
console.print("[yellow]EMAIL LIFECYCLE (v2):[/yellow]")
console.print(" new → opened → closed")
console.print(" [dim]new: Just arrived, never viewed[/dim]")
console.print(" [dim]opened: You've viewed it, not yet resolved[/dim]")
console.print(" [dim]closed: Resolved (replied or dismissed), auto-archived[/dim]")
console.print()
# Send mail (no wake)
drone @ai_mail email @seedgo "Subject" "Msg" # Send to branch
drone @ai_mail email @all "Subject" "Msg" # Broadcast to all
console.print("[yellow]USAGE:[/yellow]")
console.print(" [cyan]drone @ai_mail[/cyan] <command> [args]")
console.print(" [cyan]drone @ai_mail --help[/cyan]")
console.print()
# Check mail
drone @ai_mail inbox # List all emails
drone @ai_mail view abc123 # View email (marks as opened)
# Resolve emails
drone @ai_mail reply abc123 "Thanks!" # Reply + close + archive
drone @ai_mail close abc123 # Close single email
drone @ai_mail close abc123 def456 ghi789 # Close multiple emails
drone @ai_mail close all # Close ALL emails
""",
)
console.print(parser.format_help())
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(' [cyan]drone @ai_mail dispatch @branch "Subject" "Body"[/cyan]')
console.print(' [cyan]drone @ai_mail dispatch @branch "Subject" "Body" --fresh[/cyan]')
console.print(' [cyan]drone @ai_mail email @seedgo "Subject" "Msg"[/cyan] [dim]Send to branch[/dim]')
console.print(' [cyan]drone @ai_mail email @all "Subject" "Msg"[/cyan] [dim]Broadcast to all[/dim]')
console.print(" [cyan]drone @ai_mail inbox[/cyan] [dim]List all emails[/dim]")
console.print(" [cyan]drone @ai_mail view abc123[/cyan] [dim]View email[/dim]")
console.print(' [cyan]drone @ai_mail reply abc123 "Thanks!"[/cyan] [dim]Reply + close + archive[/dim]')
console.print(" [cyan]drone @ai_mail close abc123[/cyan] [dim]Close single email[/dim]")
console.print(" [cyan]drone @ai_mail close abc123 def456 ghi789[/cyan] [dim]Close multiple[/dim]")
console.print(" [cyan]drone @ai_mail close all[/cyan] [dim]Close ALL emails[/dim]")
console.print()
# =============================================================================
@@ -245,6 +245,7 @@ def _run_with_startup_check(
try:
popen_kwargs = {
"stdin": subprocess.DEVNULL,
"stdout": stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
"stderr": stderr_fh,
"cwd": cwd,
@@ -327,6 +328,18 @@ def main():
json_handler.log_operation("dispatch_monitor_start", {"branch": branch_email, "sender": sender})
# Self-register PID in lock file — the parent may have written its own
# PID during pre-spawn lock acquisition (DPLAN-0155), and under
# systemd-run the parent PID belongs to the caller, not the monitor.
try:
lock_path_obj = Path(lock_file)
if lock_path_obj.exists():
ld = json.loads(lock_path_obj.read_text(encoding="utf-8"))
ld["pid"] = os.getpid()
lock_path_obj.write_text(json.dumps(ld, indent=2), encoding="utf-8")
except (json.JSONDecodeError, OSError):
logger.info("[monitor] Could not self-register PID in lock file %s", lock_file)
# Open stderr log for claude output (rotate if > 500KB)
stderr_fh = None
try:
+148 -38
View File
@@ -290,6 +290,78 @@ def _check_pid_alive(pid: int) -> bool:
return True # Exists but can't check — assume alive
def _spawn_in_systemd_scope(monitor_cmd, branch_path, spawn_env, branch_email, lock_file_path, custom_message, status):
"""Spawn monitor in its own systemd unit to survive cgroup cleanup (td-48).
When wake_branch() runs inside a systemd oneshot service (e.g.
daemon-tick.timer), the default KillMode=control-group sends SIGTERM to
every process in the cgroup once the main process exits — killing the
detached monitor and its claude child. systemd-run --user creates a
transient service unit with its own cgroup so the monitor survives.
Returns True on success, False to fall back to direct Popen.
"""
unit_name = f"dispatch-{branch_email.lstrip('@')}"
env_file = branch_path / "logs" / ".dispatch_env"
try:
with open(env_file, "w", encoding="utf-8") as ef:
for key, val in spawn_env.items():
if "\n" not in str(val):
ef.write(f"{key}={val}\n")
env_file.chmod(0o600)
except OSError as e:
logger.warning("[wake] Failed to write env file for systemd-run: %s", e)
return False
systemd_cmd = [
"systemd-run",
"--user",
"--unit",
unit_name,
"--collect",
"--property",
f"WorkingDirectory={branch_path}",
"--property",
f"EnvironmentFile={env_file}",
"--property",
"StandardInput=null",
"--",
] + monitor_cmd
try:
result = subprocess.run(systemd_cmd, capture_output=True, text=True, timeout=15)
if result.returncode != 0:
logger.warning("[wake] systemd-run failed (rc=%d): %s", result.returncode, result.stderr.strip())
return False
except (subprocess.SubprocessError, OSError) as e:
logger.warning("[wake] systemd-run failed: %s", e)
return False
try:
pid_result = subprocess.run(
["systemctl", "--user", "show", f"{unit_name}.service", "-p", "MainPID", "--value"],
capture_output=True,
text=True,
timeout=5,
)
monitor_pid = int(pid_result.stdout.strip())
if monitor_pid > 0:
lock_data = {
"pid": monitor_pid,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"branch": str(branch_path),
"subject": custom_message or "daemon wake",
}
with open(lock_file_path, "w", encoding="utf-8") as f:
json.dump(lock_data, f, indent=2)
except (subprocess.SubprocessError, ValueError, OSError) as e:
logger.info("[wake] Could not query systemd unit PID: %s", e)
status.ok("spawn", f"Monitor started via systemd scope ({unit_name})")
return True
# ─── Branch Resolution ──────────────────────────────────
@@ -487,54 +559,92 @@ def wake_branch(
return status, False
status.ok("lock-acquire", "Dispatch lock acquired")
try:
process = subprocess.Popen(
# When inside a systemd oneshot service (e.g. daemon-tick.timer), the
# default KillMode=control-group sends SIGTERM to all cgroup members
# when the service exits — killing the detached monitor. Escape by
# launching the monitor in its own transient systemd unit (td-48).
spawned_via_scope = False
monitor_pid = 0
if os.environ.get("INVOCATION_ID") and shutil.which("systemd-run"):
spawned_via_scope = _spawn_in_systemd_scope(
monitor_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
cwd=str(branch_path),
env=spawn_env,
branch_path,
spawn_env,
email,
lock_file_path,
custom_message,
status,
)
monitor_pid = process.pid
if not spawned_via_scope:
try:
process = subprocess.Popen(
monitor_cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
cwd=str(branch_path),
env=spawn_env,
)
# Update lock with real monitor PID
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_data = {
"pid": monitor_pid,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"branch": str(branch_path),
"subject": custom_message or "manual wake",
}
with open(lock_file, "w", encoding="utf-8") as f:
json.dump(lock_data, f, indent=2)
monitor_pid = process.pid
status.ok("spawn", f"Monitor started (PID {monitor_pid})")
# Update lock with real monitor PID
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_data = {
"pid": monitor_pid,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
"branch": str(branch_path),
"subject": custom_message or "manual wake",
}
with open(lock_file, "w", encoding="utf-8") as f:
json.dump(lock_data, f, indent=2)
except FileNotFoundError as e:
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
logger.warning("[wake] Spawn failed — script not found: %s", e)
status.fail("spawn", "Python or monitor script not found")
return status, False
except Exception as e:
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
logger.warning("[wake] Spawn failed for %s: %s", branch_email, e)
status.fail("spawn", f"{type(e).__name__}: {e}")
return status, False
status.ok("spawn", f"Monitor started (PID {monitor_pid})")
except FileNotFoundError as e:
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
logger.warning("[wake] Spawn failed — script not found: %s", e)
status.fail("spawn", "Python or monitor script not found")
return status, False
except Exception as e:
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
logger.warning("[wake] Spawn failed for %s: %s", branch_email, e)
status.fail("spawn", f"{type(e).__name__}: {e}")
return status, False
# Step 9: Liveness check (brief wait then verify)
time.sleep(2)
if _check_pid_alive(monitor_pid):
status.ok("alive", f"Agent responding (PID {monitor_pid} alive)")
if spawned_via_scope:
_unit = f"dispatch-{email.lstrip('@')}"
try:
check = subprocess.run(
["systemctl", "--user", "is-active", f"{_unit}.service"],
capture_output=True,
text=True,
timeout=5,
)
if check.stdout.strip() == "active":
status.ok("alive", f"Agent responding (unit {_unit} active)")
else:
status.fail("alive", f"Agent died immediately ({check.stdout.strip()})")
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
return status, False
except Exception as e:
logger.info("[wake] Cannot verify systemd unit %s: %s", _unit, e)
status.warn("alive", "Cannot verify systemd unit status — assuming running")
else:
status.fail("alive", f"Agent died immediately (PID {monitor_pid})")
# Clean up lock
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
return status, False
if _check_pid_alive(monitor_pid):
status.ok("alive", f"Agent responding (PID {monitor_pid} alive)")
else:
status.fail("alive", f"Agent died immediately (PID {monitor_pid})")
lock_file = branch_path / ".ai_mail.local" / ".dispatch.lock"
lock_file.unlink(missing_ok=True)
return status, False
# Desktop notification
notif_body = custom_message[:80] if custom_message else "Manual wake: check inbox"
@@ -22,7 +22,7 @@ STANDARD_FOOTER = """
⚠️ TASK CHECKLIST (before marking complete):
□ SEEDGO CHECK → drone @seedgo audit @branch (80%+)
□ UPDATE MEMORIES → Your .trinity/local.json records this work
□ CLOSE FPLAN → drone @flow close <plan_id>
□ CLOSE YOUR PLAN → drone @flow close <your_plan_id> — this task's plan only, never the master/parent
□ EMAIL SENDER → drone @ai_mail email @<sender> "Subject" "Summary"
Memories = Presence. No update = No learning.
+4 -3
View File
@@ -269,6 +269,7 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
from aipass.ai_mail.apps.handlers.users.user import get_current_user
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
from aipass.ai_mail.apps.handlers.paths import find_repo_root
try:
from aipass.ai_mail.apps.handlers.central_writer import update_central
@@ -277,7 +278,7 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
update_central = None
_ai_mail_dir = Path(__file__).resolve().parents[2]
_repo_root = _ai_mail_dir.parents[2]
_repo_root = find_repo_root()
def _delivery_callback(branch_path, new_count, opened_count, total):
on_email_delivered(
@@ -350,14 +351,14 @@ def _orchestrate_dispatch_send(args: List[str]) -> bool:
def _spawn_watchdog(target: str) -> None:
"""Auto-spawn devpulse watchdog as a detached background process."""
from aipass.ai_mail.apps.handlers.registry.read import get_branch_by_email
from aipass.ai_mail.apps.handlers.paths import find_repo_root
devpulse_info = get_branch_by_email("@devpulse")
if not devpulse_info:
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse not in registry")
return
_ai_mail_dir = Path(__file__).resolve().parents[2]
_repo_root = _ai_mail_dir.parents[2]
_repo_root = find_repo_root()
devpulse_path = devpulse_info.get("path", "")
if not devpulse_path:
logger.warning("[dispatch] Cannot spawn watchdog — @devpulse has no path")
+2 -1
View File
@@ -39,10 +39,11 @@ from aipass.ai_mail.apps.handlers.registry.read import get_all_branches, get_bra
from aipass.ai_mail.apps.handlers.json import json_handler
from aipass.ai_mail.apps.handlers.email.close_ops import batch_close, batch_close_post_ops
from aipass.ai_mail.apps.handlers.email.inbox_resolve import resolve_inbox_target
from aipass.ai_mail.apps.handlers.paths import find_repo_root
from aipass.ai_mail.apps.modules.email_send import handle_send
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
_REPO_ROOT = find_repo_root()
try:
from aipass.ai_mail.apps.handlers.central_writer import update_central
@@ -35,9 +35,10 @@ from aipass.ai_mail.apps.handlers.email.send import (
)
from aipass.ai_mail.apps.handlers.email.error_dispatch import dispatch_send_error, on_email_delivered
from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args, resolve_dispatch_target
from aipass.ai_mail.apps.handlers.paths import find_repo_root
_AI_MAIL_DIR = Path(__file__).resolve().parents[2]
_REPO_ROOT = _AI_MAIL_DIR.parents[2]
_REPO_ROOT = find_repo_root()
try:
from aipass.ai_mail.apps.handlers.central_writer import update_central
@@ -1731,10 +1731,13 @@ class TestBrokerRealE2E:
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
from aipass.drone.apps.handlers.broker.client import create_identified_connection
# Set up repo root with branch dir
# Set up repo root with branch dir + .trinity marker (broker marker-walk requires it)
repo_root = tmp_path / "repo"
branch_dir = repo_root / "src" / "aipass" / "testbranch"
branch_dir.mkdir(parents=True)
trinity_dir = branch_dir / ".trinity"
trinity_dir.mkdir()
(trinity_dir / "passport.json").write_text('{"branch_info": {"branch_name": "testbranch"}}', encoding="utf-8")
target_file = branch_dir / "deleteme.txt"
target_file.write_text("delete me", encoding="utf-8")
+1 -1
View File
@@ -46,7 +46,7 @@ def test_get_footer_contains_checklist():
assert "TASK CHECKLIST" in result
assert "SEEDGO CHECK" in result
assert "UPDATE MEMORIES" in result
assert "CLOSE FPLAN" in result
assert "CLOSE YOUR PLAN" in result
assert "EMAIL SENDER" in result
+2
View File
@@ -601,6 +601,7 @@ class TestWakeBranchSpawnEnv:
local_bin = str(_Path.home() / ".local" / "bin")
monkeypatch.setenv("PATH", "/usr/bin:/bin")
monkeypatch.delenv("INVOCATION_ID", raising=False)
captured_envs: list = []
@@ -831,6 +832,7 @@ def _patch_wake_deps(monkeypatch, **overrides):
monkeypatch.setattr(wake_mod, attr, val)
monkeypatch.setattr("aipass.ai_mail.apps.handlers.dispatch.wake.time.sleep", lambda _: None)
monkeypatch.delenv("INVOCATION_ID", raising=False)
class _FakeProc:
@@ -12,9 +12,9 @@ Not suggestions. Violating = bug.
- **No writes outside own `.trinity/`.** Never create, edit, delete files anywhere else. Not code, not docs, not configs, not other branches' memories.
- **No git. Ever.** Not `git status`, not `drone @git anything`. Git is drone's world.
- **No `drone @ai_mail dispatch`.** Email only test-convention body (below). Never wake agent real work.
- **Dispatch focused work via `drone @ai_mail dispatch`** — to ONE owning branch, as the user's voice with detailed feedback. Reply routes to @aipass; I track the loop and report back. Not an orchestrator (no fleets, no running the floor — that's devpulse). Test-convention pings (below) still fine.
- **No registry / hooks / bypass.json / config edits.** Spot bug → report. Never patch.
- User asks build/fix/change something: tell them who. Offer dispatch through devpulse/drone — don't do it.
- User asks build/fix/change in another branch: name the owner, then dispatch focused work to them as the user's voice. Heavy orchestration, git, and fleets stay with devpulse.
## What I Do
+14 -2
View File
@@ -399,11 +399,23 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
results.append(CheckResult("drone", GLYPH_PASS, detail, ""))
else:
results.append(
CheckResult("drone", GLYPH_FAIL, "exit non-zero", "Ensure aipass is installed: pip install -e .")
CheckResult(
"drone",
GLYPH_FAIL,
"exit non-zero",
"Ensure aipass is installed: clone the repo and run setup.sh",
)
)
except FileNotFoundError as exc:
logger.warning("[doctor] drone not found: %s", exc)
results.append(CheckResult("drone", GLYPH_FAIL, "not found", "Ensure aipass is installed: pip install -e ."))
results.append(
CheckResult(
"drone",
GLYPH_FAIL,
"not found",
"Ensure aipass is installed: clone the repo and run setup.sh",
)
)
except subprocess.TimeoutExpired as exc:
logger.warning("[doctor] drone systems timed out: %s", exc)
results.append(CheckResult("drone", GLYPH_WARN, "timed out", ""))
+49 -5
View File
@@ -92,6 +92,11 @@ CLI_CHOICES = ["claude", "codex", "other"]
FLAG_CHOICES = ["default", "skip-permissions"]
STYLE_CHOICES = ["building-my-own-project", "improving-aipass", "just-exploring"]
TEMPLATE_EMPTY = "empty project"
TEMPLATE_AIPASS = "aipass_framework"
TEMPLATE_CHOICES = [TEMPLATE_EMPTY, TEMPLATE_AIPASS]
AIPASS_SPECIFIC_STAGES = {8, 9, 11, 12}
# --- LOCAL JSON HELPERS ---
def _read_local_json() -> dict:
@@ -597,12 +602,12 @@ def stage_10_smoke_test(non_interactive: bool = False, dry_run: bool = False) ->
if drone_bin:
console.print(f"[green]✓[/green] drone: {drone_bin}")
else:
warning("drone not on PATH — run: pip install -e .")
warning("drone not on PATH — clone the repo and run setup.sh")
if aipass_bin:
console.print(f"[green]✓[/green] aipass: {aipass_bin}")
else:
warning("aipass not on PATH — run: pip install -e .")
warning("aipass not on PATH — clone the repo and run setup.sh")
_save_stage(10, {"drone": drone_bin, "aipass": aipass_bin}, dry_run=dry_run)
return {"drone": drone_bin, "aipass": aipass_bin}
@@ -775,6 +780,7 @@ def run_init(
style: str | None = None,
no_docker: bool = False,
dry_run: bool = False,
template: str | None = None,
) -> int:
"""Run the 12-stage init flow. Returns 0 on success."""
# Pre-flight: refuse to run inside existing projects or agent dirs
@@ -783,9 +789,20 @@ def run_init(
console.print(f"[red]✗[/red] {err}")
return 1
# Ensure scaffold exists (creates registry, .aipass, etc. if missing)
# Template selection — before scaffold
if template is None:
if non_interactive:
template = TEMPLATE_EMPTY
else:
template = _choose(
"Choose a project template:",
TEMPLATE_CHOICES,
default=TEMPLATE_EMPTY,
)
# Ensure scaffold exists — only for aipass_framework
cwd = Path.cwd()
if not list(cwd.glob("*_REGISTRY.json")):
if template == TEMPLATE_AIPASS and not list(cwd.glob("*_REGISTRY.json")):
from aipass.aipass.apps.handlers.init.bootstrap import init_project
if not dry_run:
@@ -804,7 +821,7 @@ def run_init(
if last_done > 0:
warning(f"Resuming from stage {last_done + 1}...")
accumulated: Dict[str, Any] = {}
accumulated: Dict[str, Any] = {"template": template}
stage_fns = [
(1, lambda: stage_1_welcome(dry_run=dry_run)),
@@ -834,6 +851,9 @@ def run_init(
for stage_num, fn in stage_fns:
if stage_num <= last_done:
continue
if stage_num in AIPASS_SPECIFIC_STAGES and template != TEMPLATE_AIPASS:
logger.info("[init_flow] skipping stage %d (not aipass_framework)", stage_num)
continue
try:
result = fn() or {}
accumulated.update(result)
@@ -846,6 +866,11 @@ def run_init(
warning(f"Stage {stage_num} error: {exc} — continuing.")
_save_stage(stage_num, {"error": str(exc)}, dry_run=dry_run)
if template != TEMPLATE_AIPASS:
console.print()
console.print("[green]✓[/green] Project initialized.")
console.print("[dim]Run 'aipass init agent <name>' to add an agent.[/dim]")
return 0
@@ -878,8 +903,10 @@ def print_help() -> None:
console.print(" [green]aipass init run --non-interactive[/green] [dim]# CI/headless[/dim]")
console.print(" [green]aipass init run --name YourName[/green] [dim]# pre-fill name[/dim]")
console.print(" [green]aipass init run --cli claude[/green] [dim]# pre-fill CLI[/dim]")
console.print(" [green]aipass init run --template <name>[/green] [dim]# select template[/dim]")
console.print(" [green]aipass init run --no-docker[/green] [dim]# skip docker offer[/dim]")
console.print(" [green]aipass init run --dry-run[/green] [dim]# walk all stages, no writes[/dim]")
console.print(" [green]aipass init --list[/green] [dim]# list available templates[/dim]")
console.print()
console.print("[yellow]STAGES:[/yellow] 12 stages, each saved — resume on ctrl-C")
console.print()
@@ -1008,6 +1035,15 @@ def handle_command(command: str, args: list[str]) -> bool:
sys.exit(_handle_init_update(args[1:]))
return True
if args[0] == "--list":
console.print()
console.print("[bold cyan]Available project templates:[/bold cyan]")
for t in TEMPLATE_CHOICES:
marker = " [dim](default)[/dim]" if t == TEMPLATE_EMPTY else ""
console.print(f" • {t}{marker}")
console.print()
return True
if args[0] == "run" or args[0].startswith("--"):
run_args = args[1:] if args[0] == "run" else args
non_interactive = "--non-interactive" in run_args
@@ -1022,6 +1058,7 @@ def handle_command(command: str, args: list[str]) -> bool:
name = _flag_value("--name")
cli = _flag_value("--cli")
style = _flag_value("--style")
template = _flag_value("--template")
no_docker = "--no-docker" in run_args
dry_run = "--dry-run" in run_args
@@ -1032,6 +1069,7 @@ def handle_command(command: str, args: list[str]) -> bool:
style=style,
no_docker=no_docker,
dry_run=dry_run,
template=template,
)
json_handler.log_operation(
"init_run",
@@ -1040,6 +1078,12 @@ def handle_command(command: str, args: list[str]) -> bool:
sys.exit(result)
return True
# Template name as positional arg
if args[0] in TEMPLATE_CHOICES:
result = run_init(template=args[0])
sys.exit(result)
return True
# Positional args = target path and/or project name for scaffold
err = _preflight_check()
if err:
+120 -5
View File
@@ -16,6 +16,10 @@ from unittest.mock import MagicMock, patch
import pytest
from aipass.aipass.apps.modules.init_flow import (
AIPASS_SPECIFIC_STAGES,
TEMPLATE_AIPASS,
TEMPLATE_CHOICES,
TEMPLATE_EMPTY,
TOTAL_STAGES,
_get_last_completed_stage,
_get_setup_progress,
@@ -287,7 +291,7 @@ class TestRunInit:
assert result == 0
def test_non_interactive_runs_all_stages(self, tmp_local_json) -> None:
"""non_interactive=True runs all 12 stages from fresh state."""
"""non_interactive=True with aipass_framework runs all 12 stages."""
patches = self._patch_all_stages()
mocks = []
ctx = __import__("contextlib").ExitStack()
@@ -296,7 +300,7 @@ class TestRunInit:
with ctx:
with patch("aipass.aipass.apps.modules.init_flow.json_handler"):
with patch("aipass.aipass.apps.modules.init_flow.console"):
result = run_init(non_interactive=True)
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
assert result == 0
def test_keyboard_interrupt_pauses_gracefully(self, tmp_local_json) -> None:
@@ -304,7 +308,7 @@ class TestRunInit:
with patch("aipass.aipass.apps.modules.init_flow.stage_1_welcome", side_effect=KeyboardInterrupt):
with patch("aipass.aipass.apps.modules.init_flow.console"):
with patch("aipass.aipass.apps.modules.init_flow.warning"):
result = run_init(non_interactive=False)
result = run_init(non_interactive=False, template=TEMPLATE_EMPTY)
assert result == 0
def test_stage_error_continues(self, tmp_local_json) -> None:
@@ -336,7 +340,7 @@ class TestRunInit:
warning=MagicMock(),
console=MagicMock(),
):
result = run_init(non_interactive=True)
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
assert result == 0
def test_resumes_from_last_completed(self, tmp_local_json_with_progress: Path) -> None:
@@ -361,7 +365,7 @@ class TestRunInit:
warning=MagicMock(),
console=MagicMock(),
):
run_init(non_interactive=True)
run_init(non_interactive=True, template=TEMPLATE_AIPASS)
stage_1_mock.assert_not_called()
stage_4_mock.assert_called_once()
@@ -719,3 +723,114 @@ class TestInitUpdateRegistrySync:
):
rc = _handle_init_update([str(tmp_path)])
assert rc == 0
# =============================================================================
# TestTemplateSelector
# =============================================================================
class TestTemplateSelector:
"""Tests for the template selector in aipass init."""
@staticmethod
def _stage_patches():
"""Return patches for all 12 stage functions as no-ops."""
stage_names = [
"stage_1_welcome",
"stage_2_system_detect",
"stage_3_doctor",
"stage_4_user_profile",
"stage_5_style_questions",
"stage_6_tool_choice",
"stage_7_docker_offer",
"stage_8_first_agent",
"stage_9_ping_sweep",
"stage_10_smoke_test",
"stage_11_handoff",
"stage_12_done",
]
return {name: MagicMock(return_value={}) for name in stage_names}
def test_empty_project_default_skips_scaffold(self, tmp_local_json) -> None:
"""empty project (default) = no scaffold, stages 8,9,11,12 skipped."""
mocks = self._stage_patches()
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
result = run_init(non_interactive=True, template=TEMPLATE_EMPTY)
assert result == 0
for name in (
"stage_1_welcome",
"stage_2_system_detect",
"stage_3_doctor",
"stage_4_user_profile",
"stage_5_style_questions",
"stage_6_tool_choice",
"stage_7_docker_offer",
"stage_10_smoke_test",
):
assert mocks[name].called, f"{name} should have been called"
for name in ("stage_8_first_agent", "stage_9_ping_sweep", "stage_11_handoff", "stage_12_done"):
assert not mocks[name].called, f"{name} should NOT have been called"
def test_aipass_framework_runs_full_scaffold(self, tmp_local_json) -> None:
"""aipass_framework = full scaffold + all 12 stages."""
mocks = self._stage_patches()
with patch.multiple(_MOD, console=MagicMock(), warning=MagicMock(), **mocks):
with patch(
"aipass.aipass.apps.handlers.init.bootstrap.init_project",
return_value={},
):
result = run_init(non_interactive=True, template=TEMPLATE_AIPASS)
assert result == 0
for name in mocks:
assert mocks[name].called, f"{name} should have been called"
def test_list_flag_shows_catalog(self) -> None:
"""aipass init --list shows the catalog (not swallowed into run)."""
with patch(f"{_MOD}.console") as mock_console:
result = handle_command("init", ["--list"])
assert result is True
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
for t in TEMPLATE_CHOICES:
assert t in printed
def test_template_flag_form_works(self, tmp_local_json) -> None:
"""aipass init run --template aipass_framework passes template to run_init."""
with patch(f"{_MOD}.run_init", return_value=0) as mock_run:
with pytest.raises(SystemExit):
handle_command("init", ["run", "--template", TEMPLATE_AIPASS])
mock_run.assert_called_once()
_, kwargs = mock_run.call_args
assert kwargs["template"] == TEMPLATE_AIPASS
def test_positional_template_routes_to_run_init(self, tmp_local_json) -> None:
"""aipass init aipass_framework routes to run_init with template."""
with patch(f"{_MOD}.run_init", return_value=0) as mock_run:
with pytest.raises(SystemExit):
handle_command("init", [TEMPLATE_AIPASS])
mock_run.assert_called_once()
_, kwargs = mock_run.call_args
assert kwargs["template"] == TEMPLATE_AIPASS
def test_positional_path_still_works(self, tmp_local_json) -> None:
"""Non-template positional args still route to scaffold."""
with patch(f"{_MOD}._preflight_check", return_value=None):
with patch(f"{_MOD}._handle_init_scaffold", return_value=0) as mock_scaffold:
with pytest.raises(SystemExit):
handle_command("init", ["/tmp/test-proj"])
mock_scaffold.assert_called_once_with(["/tmp/test-proj"])
def test_pip_hints_say_clone(self, tmp_local_json) -> None:
"""in-product hints say clone/setup.sh, not pip."""
with patch(f"{_MOD}.console"):
with patch(f"{_MOD}.warning") as mock_warn:
with patch(f"{_MOD}.shutil.which", return_value=None):
stage_10_smoke_test()
for call in mock_warn.call_args_list:
msg = call[0][0].lower()
assert "setup.sh" in msg
assert "pip" not in msg
def test_aipass_specific_stages_constant(self) -> None:
"""AIPASS_SPECIFIC_STAGES contains exactly {8, 9, 11, 12}."""
assert AIPASS_SPECIFIC_STAGES == {8, 9, 11, 12}
+8 -7
View File
@@ -5,8 +5,8 @@
> Centralized external API gateway — authenticated service clients for all external APIs
**Module:** `aipass.api` | **Role:** `api_gateway`
**Seedgo:** 100% (37/37 at 100%) | **Tests:** 504 pass | **Functions:** 82 public (82 tested)
**Last Updated:** 2026-06-15
**Seedgo:** 100% (38/38 at 100%) | **Tests:** 515 pass | **Functions:** 84 public (84 tested)
**Last Updated:** 2026-06-24
---
@@ -68,7 +68,7 @@ api/
│ │ └── usage/aggregation.py, cleanup.py, tracking.py
│ └── integrations/ # Private driver space (gitignored)
│ └── {project}/driver.py
└── tests/ # 504 tests across 28 files
└── tests/ # 515 tests across 28 files
```
Three-tier: entry point routes to modules (orchestration), modules delegate to handlers (business logic). Modules auto-discovered from `apps/modules/*.py` via `handle_command()`.
@@ -88,11 +88,12 @@ service = get_drive_service(thread_safe=True) # For concurrent workers
from aipass.api.apps.modules.google_client import get_google_service
service = get_google_service("calendar", "v3")
from aipass.api.apps.modules.secrets import get_secret, list_secrets
from aipass.api.apps.modules.secrets import get_secret, set_secret, list_secrets
token = get_secret("telegram", "bot") # Returns bot_token string
config = get_secret("telegram", "bot", as_json=True) # Returns full dict
slugs = list_secrets("telegram") # Returns ["bot", "webhook", ...]
# CLI never prints raw values — use the Python API above for programmatic access
set_secret("telegram", "newbot", cfg, as_json=True) # Writes ~/.secrets/aipass/telegram/newbot.json
slugs = list_secrets("telegram") # Returns ["bot", "newbot", ...]
# Values never reach stdout — use the Python API above for programmatic access
```
---
@@ -128,6 +129,6 @@ Private drivers in `apps/integrations/{project}/driver.py` (gitignored) register
---
*Last Updated: 2026-05-16*
*Last Updated: 2026-06-24*
[← Back to AIPass](../../../README.md)
+51 -2
View File
@@ -9,17 +9,19 @@
"""
Secrets Store Handler
Reads structured secrets from ~/.secrets/aipass/<provider>/<slug>.
Reads and writes structured secrets in ~/.secrets/aipass/<provider>/<slug>.
Supports JSON config files and raw secret files.
Functions:
get_secret() - Read a secret by provider/slug
set_secret() - Write a secret to the provider store
list_secrets() - List available slugs for a provider
"""
import json
import os
from pathlib import Path
from typing import Any, List, Optional
from typing import Any, List, Optional, Union
from aipass.prax import logger
from aipass.api.apps.handlers.json import json_handler
@@ -115,6 +117,53 @@ def list_secrets(provider: str) -> List[str]:
return sorted(slugs)
# ==============================================
# SECRET WRITING
# ==============================================
def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path:
"""
Write a secret to the provider store.
Destination: ~/.secrets/aipass/<provider>/<slug>.json
Args:
provider: Provider directory name (e.g., 'telegram')
slug: Secret identifier (without .json extension)
value: Secret value — string or dict
as_json: If True, json.dump the value; else write as plain string
Returns:
Path to the written file
Raises:
OSError: If directory creation or file write fails
"""
provider_dir = SECRETS_BASE / provider
provider_dir.mkdir(parents=True, exist_ok=True)
os.chmod(provider_dir, 0o700)
target = provider_dir / f"{slug}.json"
if as_json:
content = json.dumps(value, indent=2).encode("utf-8")
else:
content = json.dumps(str(value)).encode("utf-8")
fd = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(fd, content)
finally:
os.close(fd)
json_handler.log_operation(
"secret_written",
{"provider": provider, "slug": slug, "format": "json" if as_json else "raw"},
)
return target
# ==============================================
# PRIVATE HELPERS
# ==============================================
+42 -49
View File
@@ -250,55 +250,48 @@ def get_validation_rules(provider: str) -> dict:
def print_help():
"""Print help output for API key management"""
import argparse
parser = argparse.ArgumentParser(
prog="drone @api",
description="API Key Management Module - Manage API keys and credentials",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS:
get-key - Retrieve API key for a provider
get-secret - Read secret from provider store
validate - Validate API key
list-providers - List available providers
init - Initialize .env template
USAGE:
drone @api <command> [args]
drone @api --help
EXAMPLES:
# Get key for provider
drone @api get-key openrouter
# Check if a secret exists (masked summary, no raw value)
drone @api get-secret telegram/bot
# Write secret to a protected file
drone @api get-secret telegram/bot --out /tmp/token.txt
# Write secret as JSON to a protected file
drone @api get-secret telegram/bot --out /tmp/bot.json --json
# List secrets for a provider
drone @api get-secret telegram --list
# Programmatic access (in-process, no stdout):
# from aipass.api.apps.modules.secrets import get_secret
# Validate key
drone @api validate openrouter
# List providers
drone @api list-providers
# Initialize environment
drone @api init
""",
)
console.print(parser.format_help())
"""Print drone-compliant help output with Rich markup"""
console.print()
console.print("[bold cyan]API_KEY — Manage API keys and credentials[/bold cyan]")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" [cyan]get-key[/cyan] [dim]Retrieve API key for a provider[/dim]")
console.print(" [cyan]get-secret[/cyan] [dim]Read secret from provider store[/dim]")
console.print(" [cyan]validate[/cyan] [dim]Validate API key[/dim]")
console.print(" [cyan]list-providers[/cyan] [dim]List available providers[/dim]")
console.print(" [cyan]init[/cyan] [dim]Initialize .env template[/dim]")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [cyan]drone @api[/cyan] <command> [args]")
console.print(" [cyan]drone @api[/cyan] --help")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" [cyan]drone @api get-key openrouter[/cyan]")
console.print()
console.print(" [dim]# Check if a secret exists (masked summary, no raw value)[/dim]")
console.print(" [cyan]drone @api get-secret telegram/bot[/cyan]")
console.print()
console.print(" [dim]# Write secret to a protected file[/dim]")
console.print(" [cyan]drone @api get-secret telegram/bot --out /tmp/token.txt[/cyan]")
console.print()
console.print(" [dim]# Write secret as JSON to a protected file[/dim]")
console.print(" [cyan]drone @api get-secret telegram/bot --out /tmp/bot.json --json[/cyan]")
console.print()
console.print(" [dim]# List secrets for a provider[/dim]")
console.print(" [cyan]drone @api get-secret telegram --list[/cyan]")
console.print()
console.print(" [dim]# Programmatic access (in-process, no stdout):[/dim]")
console.print(" [dim]from aipass.api.apps.modules.secrets import get_secret[/dim]")
console.print()
console.print(" [dim]# Validate key[/dim]")
console.print(" [cyan]drone @api validate openrouter[/cyan]")
console.print()
console.print(" [dim]# List providers[/dim]")
console.print(" [cyan]drone @api list-providers[/cyan]")
console.print()
console.print(" [dim]# Initialize environment[/dim]")
console.print(" [cyan]drone @api init[/cyan]")
console.print()
if __name__ == "__main__":
+19 -25
View File
@@ -82,31 +82,25 @@ def print_introspection() -> None:
def print_help() -> None:
"""Print module help."""
import argparse
parser = argparse.ArgumentParser(
prog="drone @api",
description="Google Client - Google API authentication and service access",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS (via drone @api):
validate google - Check Google OAuth2 credentials
reauth google - Re-run OAuth2 flow for Google
CROSS-BRANCH API:
from aipass.api.apps.modules.google_client import get_drive_service
service = get_drive_service()
CREDENTIAL SETUP:
1. Get OAuth client secret from Google Cloud Console
2. Save as: ~/.secrets/aipass/google_client_secret.json
3. Run: drone @api reauth google
4. Complete OAuth consent in browser
5. Credentials saved to: ~/.secrets/aipass/google_creds.json
""",
)
console.print(parser.format_help())
"""Print drone-compliant help output with Rich markup"""
console.print()
console.print("[bold cyan]GOOGLE_CLIENT — Google API authentication and service access[/bold cyan]")
console.print()
console.print("[yellow]COMMANDS:[/yellow] [dim](via drone @api)[/dim]")
console.print(" [cyan]validate google[/cyan] [dim]Check Google OAuth2 credentials[/dim]")
console.print(" [cyan]reauth google[/cyan] [dim]Re-run OAuth2 flow for Google[/dim]")
console.print()
console.print("[yellow]CROSS-BRANCH API:[/yellow]")
console.print(" [dim]from aipass.api.apps.modules.google_client import get_drive_service[/dim]")
console.print(" [dim]service = get_drive_service()[/dim]")
console.print()
console.print("[yellow]CREDENTIAL SETUP:[/yellow]")
console.print(" [dim]1.[/dim] Get OAuth client secret from Google Cloud Console")
console.print(" [dim]2.[/dim] Save as: [cyan]~/.secrets/aipass/google_client_secret.json[/cyan]")
console.print(" [dim]3.[/dim] Run: [cyan]drone @api reauth google[/cyan]")
console.print(" [dim]4.[/dim] Complete OAuth consent in browser")
console.print(" [dim]5.[/dim] Credentials saved to: [cyan]~/.secrets/aipass/google_creds.json[/cyan]")
console.print()
# =============================================
@@ -52,62 +52,39 @@ def print_introspection():
def print_help():
"""Print module help with argparse"""
import argparse
parser = argparse.ArgumentParser(
prog="drone @api",
description="OpenRouter Client - Manage LLM API connections",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS:
test - Test OpenRouter connection
call - Make API call to model
models - List available models
status - Check connection status
USAGE:
drone @api test
drone @api call <prompt> [--model MODEL]
drone @api models
drone @api status
ARGUMENTS:
prompt - Prompt to send to the model
--model - Model to use (optional)
EXAMPLES:
# Test OpenRouter connection
drone @api test
# Make an API call
drone @api call "What is AI?" --model gpt-4
# List available models
drone @api models
# Check connection status
drone @api status
""",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# test command
subparsers.add_parser("test", help="Test OpenRouter connection")
# call command
call_parser = subparsers.add_parser("call", help="Make API call to model")
call_parser.add_argument("prompt", help="Prompt to send")
call_parser.add_argument("--model", help="Model to use")
# models command
subparsers.add_parser("models", help="List available models")
# status command
subparsers.add_parser("status", help="Check connection status")
console.print(parser.format_help())
"""Print drone-compliant help output with Rich markup"""
console.print()
console.print("[bold cyan]OPENROUTER_CLIENT — Manage LLM API connections[/bold cyan]")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" [cyan]test[/cyan] [dim]Test OpenRouter connection[/dim]")
console.print(" [cyan]call[/cyan] [dim]Make API call to model[/dim]")
console.print(" [cyan]models[/cyan] [dim]List available models[/dim]")
console.print(" [cyan]status[/cyan] [dim]Check connection status[/dim]")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [cyan]drone @api test[/cyan]")
console.print(" [cyan]drone @api call[/cyan] <prompt> [--model MODEL]")
console.print(" [cyan]drone @api models[/cyan]")
console.print(" [cyan]drone @api status[/cyan]")
console.print()
console.print("[yellow]ARGUMENTS:[/yellow]")
console.print(" [cyan]prompt[/cyan] [dim]Prompt to send to the model[/dim]")
console.print(" [cyan]--model[/cyan] [dim]Model to use (optional)[/dim]")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" [dim]# Test OpenRouter connection[/dim]")
console.print(" [cyan]drone @api test[/cyan]")
console.print()
console.print(" [dim]# Make an API call[/dim]")
console.print(' [cyan]drone @api call "What is AI?" --model gpt-4[/cyan]')
console.print()
console.print(" [dim]# List available models[/dim]")
console.print(" [cyan]drone @api models[/cyan]")
console.print()
console.print(" [dim]# Check connection status[/dim]")
console.print(" [cyan]drone @api status[/cyan]")
console.print()
def handle_command(command: str, args: List[str]) -> bool:
+29 -2
View File
@@ -9,17 +9,19 @@
"""
Secrets Module
Cross-branch in-process API for reading secrets from the provider store.
Cross-branch in-process API for the secrets provider store.
Consumers import directly instead of shelling out to the CLI.
Functions:
get_secret() - Read a secret by provider/slug
set_secret() - Write a secret to the provider store
list_secrets() - List available slugs for a provider
handle_command() - Route CLI commands (seedgo module discovery)
"""
import sys
from typing import Any, List, Optional
from pathlib import Path
from typing import Any, List, Optional, Union
from aipass.prax import logger # noqa: F401 — seedgo imports standard
from aipass.cli.apps.modules import console, header
@@ -42,6 +44,7 @@ def print_introspection():
console.print("[cyan]Available Workflows:[/cyan]")
console.print(" • get_secret() - Read secret by provider/slug")
console.print(" • set_secret() - Write secret to provider store")
console.print(" • list_secrets() - List slugs for a provider")
console.print()
@@ -96,6 +99,30 @@ def get_secret(provider: str, slug: str, as_json: bool = False) -> Optional[Any]
return result
def set_secret(provider: str, slug: str, value: Union[str, dict], *, as_json: bool = False) -> Path:
"""
Write a secret to the provider store.
This is the sanctioned cross-branch write path. Consumers call this
instead of shelling out to the CLI.
Args:
provider: Provider directory name (e.g., 'telegram')
slug: Secret identifier (without .json extension)
value: Secret value — string or dict
as_json: If True, json.dump the value; else write as plain string
Returns:
Path to the written file
Raises:
OSError: If directory creation or file write fails
"""
result = _handler.set_secret(provider, slug, value, as_json=as_json)
json_handler.log_operation("secrets_set", {"provider": provider, "slug": slug, "wrote": str(result)})
return result
def list_secrets(provider: str) -> List[str]:
"""
List available secret slugs for a provider.
+42 -73
View File
@@ -53,76 +53,44 @@ def print_introspection():
def print_help():
"""Print module help with argparse"""
import argparse
parser = argparse.ArgumentParser(
prog="drone @api",
description="Usage Tracker - Monitor API usage and costs",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
COMMANDS:
track - Track API usage
stats - Show usage statistics
session - Show session data
caller-usage - Show usage by caller
cleanup - Clean up old usage data
USAGE:
drone @api track <caller>
drone @api stats
drone @api session
drone @api caller-usage <caller>
drone @api cleanup [days]
ARGUMENTS:
caller - Caller identifier
days - Number of days to retain (default: 30)
EXAMPLES:
# Track usage for a caller
drone @api track my_application
# Show usage statistics
drone @api stats
# Show session data
drone @api session
# Show usage for specific caller
drone @api caller-usage my_application
# Cleanup data older than 60 days
drone @api cleanup 60
""",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# track command
track_parser = subparsers.add_parser("track", help="Track API usage")
track_parser.add_argument("caller", help="Caller identifier")
# stats command
subparsers.add_parser("stats", help="Show usage statistics")
# session command
subparsers.add_parser("session", help="Show session data")
# caller-usage command
caller_parser = subparsers.add_parser("caller-usage", help="Show usage by caller")
caller_parser.add_argument("caller", help="Caller identifier")
# cleanup command
cleanup_parser = subparsers.add_parser("cleanup", help="Clean up old usage data")
cleanup_parser.add_argument(
"days",
nargs="?",
default=str(DEFAULT_RETENTION_DAYS),
help=f"Days to retain (default: {DEFAULT_RETENTION_DAYS})",
)
console.print(parser.format_help())
"""Print drone-compliant help output with Rich markup"""
console.print()
console.print("[bold cyan]USAGE_TRACKER — Monitor API usage and costs[/bold cyan]")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" [cyan]track[/cyan] [dim]Track API usage[/dim]")
console.print(" [cyan]stats[/cyan] [dim]Show usage statistics[/dim]")
console.print(" [cyan]session[/cyan] [dim]Show session data[/dim]")
console.print(" [cyan]caller-usage[/cyan] [dim]Show usage by caller[/dim]")
console.print(" [cyan]cleanup[/cyan] [dim]Clean up old usage data[/dim]")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [cyan]drone @api track[/cyan] <caller>")
console.print(" [cyan]drone @api stats[/cyan]")
console.print(" [cyan]drone @api session[/cyan]")
console.print(" [cyan]drone @api caller-usage[/cyan] <caller>")
console.print(" [cyan]drone @api cleanup[/cyan] [days]")
console.print()
console.print("[yellow]ARGUMENTS:[/yellow]")
console.print(" [cyan]caller[/cyan] [dim]Caller identifier[/dim]")
console.print(" [cyan]days[/cyan] [dim]Number of days to retain (default: 30)[/dim]")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" [dim]# Track usage for a caller[/dim]")
console.print(" [cyan]drone @api track my_application[/cyan]")
console.print()
console.print(" [dim]# Show usage statistics[/dim]")
console.print(" [cyan]drone @api stats[/cyan]")
console.print()
console.print(" [dim]# Show session data[/dim]")
console.print(" [cyan]drone @api session[/cyan]")
console.print()
console.print(" [dim]# Show usage for specific caller[/dim]")
console.print(" [cyan]drone @api caller-usage my_application[/cyan]")
console.print()
console.print(" [dim]# Cleanup data older than 60 days[/dim]")
console.print(" [cyan]drone @api cleanup 60[/cyan]")
console.print()
def handle_command(command: str, args: List[str]) -> bool:
@@ -194,9 +162,10 @@ def track_usage(args: List[str]):
if result.get("success"):
metrics = result.get("metrics", {})
success(
f"Tracked: {metrics.get('tokens_prompt', 0)} prompt + {metrics.get('tokens_completion', 0)} completion tokens, ${metrics.get('total_cost', 0):.6f}"
)
prompt_t = metrics.get("tokens_prompt", 0)
comp_t = metrics.get("tokens_completion", 0)
cost = metrics.get("total_cost", 0)
success(f"Tracked: {prompt_t} prompt + {comp_t} completion tokens, ${cost:.6f}")
else:
error(f"Tracking failed: {result.get('error', 'unknown')}")
+140
View File
@@ -21,8 +21,19 @@ Tests — handlers/auth/secrets.py (get_secret, list_secrets):
- list_secrets: non-existent provider returns empty list
- list_secrets: skips dotfiles, __pycache__, directories
Tests — handlers/auth/secrets.py (set_secret):
- set_secret: writes string value to provider/slug.json
- set_secret: as_json writes JSON-serialized dict
- set_secret: creates provider directory if missing
- set_secret: file has 0o600 permissions (POSIX)
- set_secret: provider dir has 0o700 permissions (POSIX)
- set_secret: overwrites existing secret
- set_secret: round-trip with get_secret returns same value
- set_secret: round-trip with get_secret as_json returns same dict
Tests — modules/secrets.py (in-process door):
- get_secret wraps handler and logs operation
- set_secret wraps handler and logs operation
- list_secrets wraps handler
Tests — api_key.py (get_secret_cmd — hardened, no raw values to stdout):
@@ -52,6 +63,7 @@ from aipass.api.apps.modules.api_key import handle_command as _hc # noqa: F401
from aipass.api.apps.modules.secrets import handle_command as _hc2 # noqa: F401 — seedgo test_coverage detection
from aipass.api.apps.handlers.auth.secrets import (
get_secret,
set_secret,
list_secrets,
)
from aipass.api.apps.modules.api_key import get_secret_cmd
@@ -491,3 +503,131 @@ class TestGetSecretCmd:
get_secret_cmd(["empty_provider", "--list"])
mock_secrets.list_secrets.assert_called_once_with("empty_provider")
# =============================================
# set_secret (handler)
# =============================================
class TestSetSecret:
"""Verifies secret writing under various conditions."""
def test_writes_string_value(self, tmp_path: Path) -> None:
"""Writes a plain string value to provider/slug.json."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = set_secret("telegram", "bot", "my-token-value")
assert result == tmp_path / "telegram" / "bot.json"
assert json.loads(result.read_text(encoding="utf-8")) == "my-token-value"
def test_as_json_writes_dict(self, tmp_path: Path) -> None:
"""as_json=True writes JSON-serialized dict."""
data = {"bot_token": "abc123", "webhook_url": "https://example.com"}
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = set_secret("telegram", "bot", data, as_json=True)
written = json.loads(result.read_text(encoding="utf-8"))
assert written == data
def test_creates_provider_directory(self, tmp_path: Path) -> None:
"""Creates provider directory if it doesn't exist."""
assert not (tmp_path / "newprovider").exists()
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("newprovider", "cred", "value")
assert (tmp_path / "newprovider").is_dir()
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
def test_file_has_0600_permissions(self, tmp_path: Path) -> None:
"""Written file has 0o600 permissions."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
result = set_secret("telegram", "bot", "token")
file_mode = stat.S_IMODE(os.stat(result).st_mode)
assert file_mode == 0o600
@pytest.mark.skipif(sys.platform == "win32", reason="File permission checks are POSIX-only")
def test_provider_dir_has_0700_permissions(self, tmp_path: Path) -> None:
"""Provider directory has 0o700 permissions."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "token")
dir_mode = stat.S_IMODE(os.stat(tmp_path / "telegram").st_mode)
assert dir_mode == 0o700
def test_overwrites_existing_secret(self, tmp_path: Path) -> None:
"""Overwrites an existing secret file."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "old-value")
set_secret("telegram", "bot", "new-value")
content = json.loads((tmp_path / "telegram" / "bot.json").read_text(encoding="utf-8"))
assert content == "new-value"
def test_round_trip_string(self, tmp_path: Path) -> None:
"""set_secret then get_secret returns the same string value."""
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "round-trip-token")
result = get_secret("telegram", "bot")
assert result == "round-trip-token"
def test_round_trip_json(self, tmp_path: Path) -> None:
"""set_secret as_json then get_secret as_json returns the same dict."""
data = {"bot_token": "abc123", "chat_id": 42}
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER), patch(PATCH_LOGGER):
set_secret("telegram", "bot", data, as_json=True)
result = get_secret("telegram", "bot", as_json=True)
assert result == data
def test_logs_operation(self, tmp_path: Path) -> None:
"""set_secret logs the write operation via json_handler."""
mock_jh = MagicMock()
with patch(PATCH_SECRETS_BASE, tmp_path), patch(PATCH_JSON_HANDLER, mock_jh), patch(PATCH_LOGGER):
set_secret("telegram", "bot", "token")
mock_jh.log_operation.assert_called_once()
call_args = mock_jh.log_operation.call_args[0]
assert call_args[0] == "secret_written"
assert call_args[1]["provider"] == "telegram"
assert call_args[1]["slug"] == "bot"
# =============================================
# set_secret (module door)
# =============================================
class TestSetSecretModule:
"""Verifies the module-level set_secret wrapper."""
def test_set_secret_wraps_handler(self, tmp_path: Path) -> None:
"""Module set_secret delegates to handler and logs the operation."""
mock_handler = MagicMock()
mock_handler.set_secret.return_value = tmp_path / "telegram" / "bot.json"
mock_jh = MagicMock()
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
result = secrets_module.set_secret("telegram", "bot", "token-val")
assert result == tmp_path / "telegram" / "bot.json"
mock_handler.set_secret.assert_called_once_with("telegram", "bot", "token-val", as_json=False)
mock_jh.log_operation.assert_called_once()
assert mock_jh.log_operation.call_args[0][0] == "secrets_set"
def test_set_secret_as_json(self) -> None:
"""Module set_secret passes as_json through to handler."""
mock_handler = MagicMock()
mock_handler.set_secret.return_value = Path("/fake/path.json")
mock_jh = MagicMock()
data = {"bot_token": "abc"}
with patch(PATCH_MOD_HANDLER, mock_handler), patch(PATCH_MOD_JSON_HANDLER, mock_jh):
secrets_module.set_secret("telegram", "bot", data, as_json=True)
mock_handler.set_secret.assert_called_once_with("telegram", "bot", data, as_json=True)
@@ -39,7 +39,7 @@ apps/
│ ├── settings.py # Settings UI (stub — low priority)
│ ├── drive_sync.py # Drive sync (stub — DPLAN-003)
│ ├── drive_stats.py # Drive stats (stub)
│ ├── drive_test.py # Drive test (stub)
│ ├── drive_check.py # Drive check (stub — DPLAN-003)
│ └── drive_clear.py # Drive clear (stub)
└── handlers/
├── copy/ # File copying (snapshot + versioned)
@@ -47,7 +47,7 @@ apps/
├── ignore/ # .backupignore patterns + whitelist
├── json/ # JSON persistence, atomic writes, ops log
├── path/ # Backup path building
├── project/ # Config, registry, setup (.backup_system/)
├── project/ # Config, registry, setup (.backup/)
├── report/ # Result formatting
├── scan/ # Directory walking + filtering
├── state/ # Changelog, metadata, timestamps
@@ -58,14 +58,14 @@ apps/
## Integration
- **Depends on:** @prax for logging, @cli for Rich console output
- **Serves:** Any project on the PC — backups are project-owned (.backup_system/ in target root)
- **Serves:** Any project on the PC — backups are project-owned (.backup/ in target root)
## Working Habits
- Project-owned design: .backup_system/ and .backupignore live in the TARGET project, not centrally
- Project-owned design: .backup/ and .backupignore live in the TARGET project, not centrally
- Normal citizen namespace: uses `from aipass.backup.apps.modules.*` / `from aipass.backup.apps.handlers.*`
- Entry point sets AIPASS_BRANCH_NAME env var for Prax
- BUILTIN_IGNORES in patterns.py is the single source for default ignore patterns
- templates/backupignore.template is the single source for default ignore patterns
## Known Gotchas
+18
View File
@@ -0,0 +1,18 @@
{
"version": 1,
"branch": "@backup",
"jobs": [
{
"id": "wake-test",
"enabled": false,
"schedule": {
"type": "interval",
"interval_minutes": 5
},
"wake": {
"fresh": true
},
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 backup\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
}
]
}
+53 -3
View File
@@ -64,11 +64,61 @@ apps/
backup register <path> [--name <name>] # Register a project for backup
backup snapshot <path|@name> # Full mirror backup
backup versioned <path|@name> # Incremental timestamped backup
backup all <path|@name> # Snapshot + versioned
backup all <path|@name> # Snapshot + versioned + drive
backup status <path|@name> # Show backup info and history
backup --version # Show version
backup restore <path|@name> list <file> # List available versions of a file
backup restore <path|@name> file <f> <o> # Restore a file version to output path
backup settings <path|@name> # Settings UI (stub)
backup drive_sync <path|@name> # Google Drive sync (stub — DPLAN-003)
backup drive_check <path|@name> # Drive connectivity check (stub — DPLAN-003)
backup drive_stats <path|@name> # Drive storage stats (stub — DPLAN-003)
backup drive_clear <path|@name> # Clear Drive sync state (stub — DPLAN-003)
```
All 11 commands are auto-discovered by the entry point router.
---
## `.backup/` Store Structure
Each registered project gets a `.backup/` directory at its root:
```
.backup/
├── config.json # Project backup configuration
├── snapshots/ # Full mirror copies (eager — created on register)
├── versioned/ # Incremental timestamped backups (lazy)
├── logs/ # Operation logs (eager — created on register)
├── timestamps.json # Backup timing metadata (lazy)
├── changelog.json # Change history (lazy)
└── drive_tracker.json # Drive sync dedup tracker (lazy)
```
On `register`, only `snapshots/` and `logs/` are created eagerly (plus `config.json`). The rest are created lazily on first use.
**Shared namespace:** `.backup/` is NOT exclusive to @backup. Three writers use it:
- **@backup** — snapshot/versioned stores at a registered project root
- **@memory** — rollover safety copies (`rollover_backup_*.json`) written to `<branch>/.backup/` during memory overflow
- **@flow** — closed plans archived to `<repo-root>/.backup/processed_plans/` for vectorization by @memory
The root `.gitignore` covers all three with a single `.backup/` entry.
---
## How Ignores Work
Two layers — seed and runtime:
1. **`templates/backupignore.template`** — the **seed**. Read by `setup._build_backupignore()` and written into a new project's `.backupignore` at `register` time. Never consulted at backup time. If this file is missing, registration raises — an empty seed would back up everything and crash the machine.
2. **`.backupignore`** — the **runtime source of truth**. `load_spec()` reads it on every backup; the seed template is not applied. True pathspec/gitwildmatch semantics: `#` comments, `!` negation, trailing `/` for dirs, last-match-wins.
There is no static fallback. The seed IS the safety mechanism — an empty or missing `.backupignore` means back up everything (`.venv`, `node_modules`, `.git`), which can crash the machine. Keep the template sane.
- To change defaults for **new** projects → edit `templates/backupignore.template`
- To change ignores for an **existing** project → edit its `.backupignore`
The repo-root `/.backupignore` ships intentionally as the curated default so users don't snapshot junk.
---
## Integration Points
@@ -78,4 +128,4 @@ backup --version # Show version
- @cli — Rich console output
### Provides To
- Any project on the PC — backups are project-owned (.backup/ in target root)
- Any project on the PC — backups are project-owned (`.backup/` in target root)
+1
View File
@@ -73,6 +73,7 @@ def print_help() -> None:
console.print(" [green]drive_sync[/green] Sync backups to the remote drive")
console.print(" [green]drive_check[/green] Test the remote drive connection")
console.print(" [green]drive_stats[/green] Drive usage statistics")
console.print(" [green]share[/green] Upload a single file to Drive + get a shareable link")
console.print(" [green]drive_clear[/green] Clear backups from the remote drive")
console.print()
@@ -0,0 +1,200 @@
# =================== AIPass ====================
# Name: share.py
# Description: Drive sharing — upload + permission + shareable link retrieval
# Version: 1.0.0
# Created: 2026-07-01
# Modified: 2026-07-01
# =============================================
"""Drive file sharing.
Uploads a single file to Drive (under ``AIPass Backups/Shared``),
sets a read permission, and retrieves a shareable webViewLink.
Idempotent: reuses an existing file if one is found by name.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from aipass.prax import logger
from ..json import json_handler
from . import upload as upload_mod
if TYPE_CHECKING:
from .client import DriveClient
SHARE_PROJECT_NAME = "Shared"
def _get_authenticated_email(client: DriveClient) -> str | None:
"""Derive the authenticated user's email from Drive ``about`` API."""
try:
request = client.drive_service.about().get(fields="user") # type: ignore[union-attr]
result = client._api_call(request)
if result and result.get("user"):
return result["user"].get("emailAddress")
except Exception as exc:
logger.info(f"Failed to get authenticated email: {exc}")
return None
def upload_for_share(
client: DriveClient,
local_file: Path,
note: str = "",
) -> str | None:
"""Upload a file for sharing. Returns the Drive file ID or ``None``.
Idempotent: checks the ``Shared`` project folder first and reuses an
existing file (matched by name) instead of re-uploading. Falls back
to :func:`upload_single_file` for the actual upload.
"""
folder_id = client.get_or_create_project_folder(SHARE_PROJECT_NAME)
if not folder_id:
return None
existing = client._find_existing_file(local_file.name, folder_id)
if existing:
logger.info(f"File already on Drive: {existing['id']}")
return existing["id"]
client.file_tracker = {}
success = upload_mod.upload_single_file(
client,
local_file,
SHARE_PROJECT_NAME,
local_file.parent,
note=note,
)
if not success:
return None
entry = client.file_tracker.get(local_file.name, {})
return entry.get("drive_id")
def set_share_permission(
client: DriveClient,
file_id: str,
*,
public: bool = False,
) -> str | None:
"""Set a read permission on *file_id*.
*public* ``False`` (default): restricted to the authenticated user
(``type=user``, ``role=reader``). ``True``: anyone with the link
(``type=anyone``, ``role=reader``).
Returns the permission ID, or ``None`` on failure.
"""
if public:
body: dict[str, Any] = {"type": "anyone", "role": "reader"}
else:
email = _get_authenticated_email(client)
if not email:
client.last_error = "Could not determine authenticated email"
return None
body = {"type": "user", "role": "reader", "emailAddress": email}
try:
request = client.drive_service.permissions().create( # type: ignore[union-attr]
fileId=file_id,
body=body,
fields="id",
)
result = client._api_call(request)
if result:
return result.get("id")
except Exception as exc:
client.last_error = str(exc)
logger.warning(f"Failed to set permission on {file_id}: {exc}")
return None
def get_share_link(client: DriveClient, file_id: str) -> str | None:
"""Retrieve the shareable link for *file_id*.
Prefers ``webViewLink``; falls back to ``webContentLink``.
"""
try:
request = client.drive_service.files().get( # type: ignore[union-attr]
fileId=file_id,
fields="webViewLink,webContentLink",
)
result = client._api_call(request)
if result:
return result.get("webViewLink") or result.get("webContentLink")
except Exception as exc:
client.last_error = str(exc)
logger.warning(f"Failed to get share link for {file_id}: {exc}")
return None
def share_file(
client: DriveClient,
local_file: str | Path,
*,
public: bool = False,
note: str = "",
) -> dict[str, Any]:
"""Upload, share, and return a link — the complete pipeline.
Returns a dict with ``success``, ``link``, ``file_id``, and
``error`` keys.
"""
local_file = Path(local_file).resolve()
if not local_file.is_file():
return {
"success": False,
"link": None,
"file_id": None,
"error": f"Not a file: {local_file}",
}
file_id = upload_for_share(client, local_file, note=note)
if not file_id:
return {
"success": False,
"link": None,
"file_id": None,
"error": f"Upload failed: {client.last_error or 'unknown'}",
}
perm_id = set_share_permission(client, file_id, public=public)
if not perm_id:
return {
"success": False,
"link": None,
"file_id": file_id,
"error": f"Permission failed: {client.last_error or 'unknown'}",
}
link = get_share_link(client, file_id)
if not link:
return {
"success": False,
"link": None,
"file_id": file_id,
"error": f"Link retrieval failed: {client.last_error or 'unknown'}",
}
json_handler.log_operation(
"share_file",
{
"file": str(local_file),
"file_id": file_id,
"public": public,
"link": link,
},
)
return {"success": True, "link": link, "file_id": file_id, "error": None}
# =============================================
@@ -17,37 +17,11 @@ import pathspec
from ..json import json_handler
from ..path import builder
BUILTIN_IGNORES = [
".backup/",
".git/",
".svn/",
".hg/",
"__pycache__/",
".pytest_cache/",
"*.pyc",
"*.pyo",
"*.egg-info/",
".venv/",
"venv/",
".tox/",
"node_modules/",
".vscode/",
".idea/",
"*.swp",
"*.swo",
".DS_Store",
"Thumbs.db",
"build/",
"dist/",
"*.log",
".ruff_cache/",
".coverage",
]
def load_spec(project_root: str) -> pathspec.PathSpec:
"""Load a PathSpec from .backupignore at the project root.
This is the runtime source of truth — the seed template is not consulted here.
Reads raw lines — pathspec handles #comments, blanks, !negation,
anchoring, dir-only trailing /, and last-match-wins natively.
@@ -15,22 +15,22 @@ inside a target project path, and a ``.backupignore`` at the project root.
from datetime import datetime, timezone
from pathlib import Path
from ..ignore.patterns import BUILTIN_IGNORES
from ..json import json_handler
from ..path import builder
_TEMPLATE_PATH = Path(__file__).resolve().parents[3] / "templates" / "backupignore.template"
def _build_backupignore() -> str:
"""Generate .backupignore content from BUILTIN_IGNORES."""
lines = [
"# Backup System ignore patterns (gitignore-style)",
"# Lines starting with # are comments. Blank lines are ignored.",
"# Edit this file to customize. Source defaults: handlers/ignore/patterns.py",
"",
]
for pattern in BUILTIN_IGNORES:
lines.append(pattern)
return "\n".join(lines) + "\n"
"""Read the seed .backupignore content from the template file.
Raises:
FileNotFoundError: If the template is missing.
OSError: If the template cannot be read.
"""
if not _TEMPLATE_PATH.exists():
raise FileNotFoundError(f"Seed template missing: {_TEMPLATE_PATH} — cannot create a safe .backupignore")
return _TEMPLATE_PATH.read_text(encoding="utf-8")
DEFAULT_CONFIG = {
+124
View File
@@ -0,0 +1,124 @@
# =================== AIPass ====================
# Name: share.py
# Description: Share module — single-file Drive upload with shareable link
# Version: 1.0.0
# Created: 2026-07-01
# Modified: 2026-07-01
# =============================================
"""Share Module — upload a single file to Drive and return a shareable link."""
import sys
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.backup.apps.handlers.json import json_handler
MODULE_NAME = "share"
PRIMARY_COMMAND = "share"
def print_introspection():
"""Display module info and connected handlers."""
console.print(f"[bold cyan]{MODULE_NAME} Module[/bold cyan]")
console.print(f" Primary command: [yellow]{PRIMARY_COMMAND}[/yellow]")
console.print(" Status: Live — single-file Drive upload + share link")
console.print(" Handlers: drive/client, drive/upload, drive/share")
def print_help():
"""Display help for this module."""
print_introspection()
console.print()
console.print("[bold cyan]USAGE:[/bold cyan]")
console.print()
console.print(" [dim]drone @backup share <file_path> [--public][/dim]")
console.print()
console.print("[bold cyan]OPTIONS:[/bold cyan]")
console.print()
console.print(" [green]--public[/green] Share with anyone (default: restricted to owner)")
console.print()
console.print("[bold cyan]OUTPUT:[/bold cyan]")
console.print()
console.print(" Final stdout line is the shareable Google Drive link.")
console.print()
def run_share(file_path: str, *, public: bool = False) -> dict:
"""Upload a single file to Drive and return a shareable link.
Authenticates via @api, uploads (or reuses existing), sets a
share permission, and prints the webViewLink as the last line.
Args:
file_path: Path to the file to share.
public: If True, anyone-with-link; else restricted to owner.
Returns:
Result dict with success, link, file_id, error.
"""
from aipass.backup.apps.handlers.drive.client import DriveClient
from aipass.backup.apps.handlers.drive.share import share_file
client = DriveClient()
if not client.authenticate():
error = f"Drive authentication failed: {client.last_error}"
console.print(f"[red]{error}[/red]")
logger.warning(f"[backup] {error}")
return {"success": False, "link": None, "file_id": None, "error": error}
console.print(f"[dim]Uploading {file_path}...[/dim]")
result = share_file(client, file_path, public=public)
if result["success"]:
mode = "public" if public else "restricted"
console.print(f"[green]Shared ({mode}):[/green] {result['link']}")
logger.info(f"[backup] Shared {file_path} ({mode})")
else:
console.print(f"[red]Share failed:[/red] {result['error']}")
logger.warning(f"[backup] Share failed: {result['error']}")
if result.get("link"):
console.print(result["link"], highlight=False)
json_handler.log_operation(
"share_command",
{
"file": file_path,
"public": public,
"success": result["success"],
},
)
return result
def handle_command(command: str, args: list) -> bool:
"""Handle the share command. Returns True if handled."""
if command != PRIMARY_COMMAND:
return False
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
file_path = args[0]
public = "--public" in args
run_share(file_path, public=public)
return True
# =============================================
if __name__ == "__main__":
if len(sys.argv) == 1:
print_introspection()
sys.exit(0)
handle_command(PRIMARY_COMMAND, sys.argv[1:])
sys.exit(0)
+4
View File
@@ -3,3 +3,7 @@
Branch-specific templates for `BACKUP`.
Any templates this branch provides to the system or uses internally. Examples: plan templates (flow), trinity templates (memory), test templates (seedgo).
## Files
- **`backupignore.template`** — Seed content for a new project's `.backupignore`. Written at `register` time by `setup._build_backupignore()`. Edit this to change the default ignore patterns for newly registered projects.
@@ -1,6 +1,6 @@
# Backup System ignore patterns (gitignore-style)
# Lines starting with # are comments. Blank lines are ignored.
# Edit this file to customize. Source defaults: handlers/ignore/patterns.py
# Edit this file to customize. Source defaults: templates/backupignore.template
.backup/
.git/
@@ -24,5 +24,6 @@ Thumbs.db
build/
dist/
*.log
logs/
.ruff_cache/
.coverage
+50 -14
View File
@@ -11,7 +11,6 @@
import pathspec
from aipass.backup.apps.handlers.ignore.patterns import (
BUILTIN_IGNORES,
is_ignored,
load_spec,
)
@@ -240,24 +239,61 @@ class TestDotfilesIncluded:
class TestSeedTemplate:
"""BUILTIN_IGNORES is used for seeding, not runtime merge."""
"""Seed template (backupignore.template) provisions new projects."""
def test_builtin_has_ruff_cache(self):
"""Seed defaults include .ruff_cache/."""
assert ".ruff_cache/" in BUILTIN_IGNORES
def test_template_has_ruff_cache(self):
"""Seed template includes .ruff_cache/."""
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
def test_builtin_has_coverage(self):
"""Seed defaults include .coverage."""
assert ".coverage" in BUILTIN_IGNORES
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
assert ".ruff_cache/" in content
def test_build_backupignore_content(self):
"""Seed template includes ruff_cache, coverage, and pycache."""
from aipass.backup.apps.handlers.project.setup import _build_backupignore
def test_template_has_coverage(self):
"""Seed template includes .coverage."""
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
assert ".coverage" in content
def test_template_has_logs_dir(self):
"""Seed template excludes logs/ directories."""
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
assert "logs/" in content
def test_template_has_git(self):
"""Seed template excludes .git/."""
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
assert ".git/" in content
def test_template_has_venv(self):
"""Seed template excludes .venv/."""
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH
content = _TEMPLATE_PATH.read_text(encoding="utf-8")
assert ".venv/" in content
def test_build_backupignore_reads_template(self):
"""_build_backupignore returns the template content."""
from aipass.backup.apps.handlers.project.setup import _TEMPLATE_PATH, _build_backupignore
content = _build_backupignore()
assert ".ruff_cache/" in content
assert ".coverage" in content
assert "__pycache__/" in content
assert content == _TEMPLATE_PATH.read_text(encoding="utf-8")
def test_build_backupignore_raises_on_missing_template(self, tmp_path):
"""Missing template raises FileNotFoundError, not empty content."""
from unittest.mock import patch
import pytest
from aipass.backup.apps.handlers.project import setup
fake_path = tmp_path / "nonexistent.template"
with patch.object(setup, "_TEMPLATE_PATH", fake_path), pytest.raises(FileNotFoundError):
setup._build_backupignore()
def test_seed_writes_only_when_absent(self, tmp_path):
"""Seeding does not overwrite an existing .backupignore."""
+408
View File
@@ -0,0 +1,408 @@
# =================== AIPass ====================
# Name: test_share.py
# Description: Tests for share module + handler (mocked Drive API)
# Version: 1.0.0
# Created: 2026-07-01
# Modified: 2026-07-01
# =============================================
"""Tests for share — module routing, handler logic, permission paths."""
import importlib
import sys
import types
from unittest.mock import MagicMock, patch
def _build_mocks():
"""Build the standard mock dict for importing share modules."""
mocks: dict[str, object] = {}
prax = types.ModuleType("aipass.prax")
setattr(prax, "logger", MagicMock())
mocks["aipass.prax"] = prax
cli = types.ModuleType("aipass.cli")
cli_apps = types.ModuleType("aipass.cli.apps")
cli_modules = types.ModuleType("aipass.cli.apps.modules")
setattr(cli_modules, "console", MagicMock())
setattr(cli_modules, "header", MagicMock())
setattr(cli_modules, "success", MagicMock())
setattr(cli_modules, "warning", MagicMock())
setattr(cli_modules, "error", MagicMock())
mocks["aipass.cli"] = cli
mocks["aipass.cli.apps"] = cli_apps
mocks["aipass.cli.apps.modules"] = cli_modules
json_pkg = types.ModuleType("aipass.backup.apps.handlers.json")
json_handler = types.ModuleType(
"aipass.backup.apps.handlers.json.json_handler",
)
setattr(json_handler, "log_operation", MagicMock())
setattr(json_handler, "load_json", MagicMock(return_value={}))
setattr(json_handler, "save_json", MagicMock())
mocks["aipass.backup.apps.handlers.json"] = json_pkg
mocks["aipass.backup.apps.handlers.json.json_handler"] = json_handler
google_client = types.ModuleType("aipass.api.apps.modules.google_client")
setattr(google_client, "get_drive_service", MagicMock())
setattr(google_client, "api_call_with_retry", MagicMock())
mocks["aipass.api.apps.modules.google_client"] = google_client
mocks["aipass.api"] = types.ModuleType("aipass.api")
mocks["aipass.api.apps"] = types.ModuleType("aipass.api.apps")
mocks["aipass.api.apps.modules"] = types.ModuleType("aipass.api.apps.modules")
ghttp = types.ModuleType("googleapiclient.http")
setattr(ghttp, "MediaFileUpload", MagicMock())
mocks["googleapiclient"] = types.ModuleType("googleapiclient")
mocks["googleapiclient.http"] = ghttp
return mocks
def _fresh_import(module_path: str, mocks: dict):
"""Import a module with mocked dependencies, clearing stale entries."""
stale = [k for k in sys.modules if k.startswith(module_path.rsplit(".", 1)[0])]
with patch.dict(sys.modules, mocks):
for k in stale:
sys.modules.pop(k, None)
return importlib.import_module(module_path)
# ── Module routing tests ─────────────────────────────────────────────
class TestShareModuleRouting:
"""Verify handle_command routing for the share module."""
def test_handle_command_returns_true_for_primary(self) -> None:
"""handle_command returns True for the primary command."""
mocks = _build_mocks()
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
assert mod.handle_command(mod.PRIMARY_COMMAND, []) is True
def test_handle_command_returns_false_for_unknown(self) -> None:
"""handle_command returns False for unknown commands."""
mocks = _build_mocks()
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
assert mod.handle_command("nonexistent", []) is False
def test_handle_command_help(self) -> None:
"""--help flag returns True early."""
mocks = _build_mocks()
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
assert mod.handle_command(mod.PRIMARY_COMMAND, ["--help"]) is True
def test_handle_command_help_short(self) -> None:
"""-h flag returns True early."""
mocks = _build_mocks()
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
assert mod.handle_command(mod.PRIMARY_COMMAND, ["-h"]) is True
def test_handle_command_help_word(self) -> None:
"""help word returns True early."""
mocks = _build_mocks()
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
assert mod.handle_command(mod.PRIMARY_COMMAND, ["help"]) is True
def test_module_constants(self) -> None:
"""MODULE_NAME and PRIMARY_COMMAND are correct."""
mocks = _build_mocks()
mod = _fresh_import("aipass.backup.apps.modules.share", mocks)
assert mod.MODULE_NAME == "share"
assert mod.PRIMARY_COMMAND == "share"
# ── Handler tests ────────────────────────────────────────────────────
def _make_mock_client():
"""Build a mock DriveClient with chainable Drive API methods."""
client = MagicMock()
client.last_error = None
client.file_tracker = {}
client.backup_folder_id = None
client.project_folder_cache = {}
client.get_or_create_project_folder.return_value = "folder-shared-123"
client._find_existing_file.return_value = None
service = MagicMock()
client.drive_service = service
service.permissions.return_value.create.return_value = MagicMock()
service.files.return_value.get.return_value = MagicMock()
service.files.return_value.create.return_value = MagicMock()
service.files.return_value.update.return_value = MagicMock()
service.files.return_value.list.return_value = MagicMock()
service.about.return_value.get.return_value = MagicMock()
return client
class TestShareHandler:
"""Test the share handler functions with mocked Drive API."""
def test_share_file_success_public(self, tmp_path) -> None:
"""Public share uploads, sets permission, returns webViewLink."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
test_file = tmp_path / "report.pdf"
test_file.write_bytes(b"PDF content")
client._api_call.side_effect = [
{"id": "file-abc-123"},
{"id": "perm-xyz-789"},
{"webViewLink": "https://drive.google.com/file/d/file-abc-123/view"},
]
result = handler.share_file(client, str(test_file), public=True)
assert result["success"] is True
assert result["link"] == "https://drive.google.com/file/d/file-abc-123/view"
assert result["file_id"] is not None
assert result["error"] is None
def test_share_file_success_restricted(self, tmp_path) -> None:
"""Restricted share uses authenticated email for permission."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
test_file = tmp_path / "data.csv"
test_file.write_text("a,b,c")
client._api_call.side_effect = [
{"id": "file-def-456"},
{"user": {"emailAddress": "test@gmail.com"}},
{"id": "perm-abc-123"},
{"webViewLink": "https://drive.google.com/file/d/file-def-456/view"},
]
result = handler.share_file(client, str(test_file), public=False)
assert result["success"] is True
assert result["link"] is not None
assert result["error"] is None
def test_share_file_not_a_file(self, tmp_path) -> None:
"""Nonexistent path returns error."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
result = handler.share_file(client, str(tmp_path / "nonexistent.txt"))
assert result["success"] is False
assert "Not a file" in result["error"]
def test_share_file_directory_rejected(self, tmp_path) -> None:
"""Directory path returns error."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
result = handler.share_file(client, str(tmp_path))
assert result["success"] is False
assert "Not a file" in result["error"]
def test_share_file_upload_failure(self, tmp_path) -> None:
"""Upload failure surfaces error."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client.get_or_create_project_folder.return_value = None
client.last_error = "Folder creation failed"
test_file = tmp_path / "fail.txt"
test_file.write_text("content")
result = handler.share_file(client, str(test_file))
assert result["success"] is False
assert "Upload failed" in result["error"]
def test_share_file_permission_failure(self, tmp_path) -> None:
"""Permission failure after upload surfaces error."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._find_existing_file.return_value = {"id": "existing-file-id"}
client._api_call.side_effect = [
{"user": {"emailAddress": "test@gmail.com"}},
None,
]
client.last_error = "Permission denied"
test_file = tmp_path / "secret.txt"
test_file.write_text("restricted")
result = handler.share_file(client, str(test_file))
assert result["success"] is False
assert "Permission failed" in result["error"]
def test_share_file_link_retrieval_failure(self, tmp_path) -> None:
"""Link retrieval failure after permission surfaces error."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._find_existing_file.return_value = {"id": "file-id"}
client._api_call.side_effect = [
{"id": "perm-id"},
None,
]
client.last_error = "API error"
test_file = tmp_path / "doc.txt"
test_file.write_text("document")
result = handler.share_file(client, str(test_file), public=True)
assert result["success"] is False
assert "Link retrieval failed" in result["error"]
def test_idempotent_reuses_existing(self, tmp_path) -> None:
"""Existing file on Drive is reused, not re-uploaded."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._find_existing_file.return_value = {"id": "already-on-drive"}
client._api_call.side_effect = [
{"id": "perm-id"},
{"webViewLink": "https://drive.google.com/file/d/already-on-drive/view"},
]
test_file = tmp_path / "existing.txt"
test_file.write_text("already uploaded")
result = handler.share_file(client, str(test_file), public=True)
assert result["success"] is True
assert result["file_id"] == "already-on-drive"
client.get_or_create_project_folder.assert_called_once_with("Shared")
def test_webcontentlink_fallback(self, tmp_path) -> None:
"""Falls back to webContentLink when webViewLink is absent."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._find_existing_file.return_value = {"id": "file-id"}
client._api_call.side_effect = [
{"id": "perm-id"},
{"webContentLink": "https://drive.google.com/uc?id=file-id"},
]
test_file = tmp_path / "download.bin"
test_file.write_bytes(b"\x00\x01")
result = handler.share_file(client, str(test_file), public=True)
assert result["success"] is True
assert "uc?id=file-id" in result["link"]
class TestSetSharePermission:
"""Test permission-setting specifically."""
def test_public_permission_body(self, tmp_path) -> None:
"""Public permission uses type=anyone, role=reader."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._api_call.return_value = {"id": "perm-id"}
handler.set_share_permission(client, "file-123", public=True)
call_args = client.drive_service.permissions().create.call_args
assert call_args.kwargs["body"]["type"] == "anyone"
assert call_args.kwargs["body"]["role"] == "reader"
def test_restricted_permission_body(self, tmp_path) -> None:
"""Restricted permission uses type=user with authenticated email."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._api_call.side_effect = [
{"user": {"emailAddress": "user@example.com"}},
{"id": "perm-id"},
]
handler.set_share_permission(client, "file-123", public=False)
call_args = client.drive_service.permissions().create.call_args
assert call_args.kwargs["body"]["type"] == "user"
assert call_args.kwargs["body"]["emailAddress"] == "user@example.com"
def test_restricted_fails_without_email(self) -> None:
"""Restricted permission fails when email lookup returns None."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._api_call.return_value = None
result = handler.set_share_permission(client, "file-123", public=False)
assert result is None
assert "email" in client.last_error.lower()
class TestGetShareLink:
"""Test link retrieval."""
def test_prefers_webviewlink(self) -> None:
"""webViewLink is preferred over webContentLink."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._api_call.return_value = {
"webViewLink": "https://view-link",
"webContentLink": "https://content-link",
}
link = handler.get_share_link(client, "file-id")
assert link == "https://view-link"
def test_falls_back_to_webcontentlink(self) -> None:
"""Falls back to webContentLink when webViewLink is None."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._api_call.return_value = {
"webViewLink": None,
"webContentLink": "https://content-link",
}
link = handler.get_share_link(client, "file-id")
assert link == "https://content-link"
def test_returns_none_on_api_failure(self) -> None:
"""Returns None when API call fails."""
mocks = _build_mocks()
handler = _fresh_import("aipass.backup.apps.handlers.drive.share", mocks)
client = _make_mock_client()
client._api_call.return_value = None
link = handler.get_share_link(client, "file-id")
assert link is None
# =============================================
+18
View File
@@ -0,0 +1,18 @@
{
"version": 1,
"branch": "@cli",
"jobs": [
{
"id": "wake-test",
"enabled": false,
"schedule": {
"type": "interval",
"interval_minutes": 5
},
"wake": {
"fresh": true
},
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST \u2014 cli\" \"Woke via @daemon systemd timer. No memory touched.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
}
]
}
+3 -2
View File
@@ -7,10 +7,11 @@
"enabled": false,
"schedule": {
"type": "interval",
"interval_minutes": 1
"interval_minutes": 5
},
"wake": {
"fresh": true
"fresh": true,
"model": "haiku"
},
"prompt": "AUTOMATED DAEMON TEST. Do ONLY this: run drone @ai_mail email @devpulse \"DAEMON TEST FIRED\" \"Wake received from @daemon scheduler. No memory updated.\" then STOP. Do NOT run startup, do NOT update memory, do NOT do anything else."
}
+60 -42
View File
@@ -5,18 +5,6 @@
"description": "Standards bypass configuration for this branch"
},
"bypass": [
{
"file": "apps/scheduler_cron.py",
"standard": "naming",
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function references assigned at module level from task_registry imports, not constants",
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
},
{
"file": "apps/modules/scheduler_ops.py",
"standard": "naming",
"reason": "False positive: get_due_tasks, mark_dispatching, mark_completed are function re-exports from task_registry, not constants",
"pattern": "get_due_tasks|mark_dispatching|mark_completed"
},
{
"file": "apps/modules/schedule.py",
"standard": "naming",
@@ -47,30 +35,12 @@
"reason": "False positive: red_flags, activity, health_data are local variables inside functions, not module-level constants",
"pattern": "red_flags|activity|health_data"
},
{
"file": "apps/handlers/schedule/task_registry.py",
"standard": "naming",
"reason": "False positive: task_result, email_body, to_branch are local variables inside functions, not module-level constants",
"pattern": "task_result|email_body|to_branch"
},
{
"file": "apps/handlers/actions/actions_registry.py",
"standard": "naming",
"reason": "False positive: action is a local variable inside functions, not a module-level constant. File name matches its domain (actions/actions_registry.py) — renaming would lose specificity",
"pattern": "action|actions_registry"
},
{
"file": "apps/handlers/actions/action_processor.py",
"standard": "naming",
"reason": "False positive: load_registry, is_action_due, update_last_run are function references from try/except import fallback, not constants",
"pattern": "load_registry|is_action_due|update_last_run"
},
{
"file": "apps/scheduler_cron.py",
"standard": "architecture",
"reason": "Entry point script — lives in apps/ root by design, not a module or handler",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "apps/daemon_wakeup.py",
"standard": "architecture",
@@ -83,18 +53,6 @@
"reason": "Branch entry point — lives in apps/ root by design per AIPass convention",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "apps/scheduler_cron.py",
"standard": "encapsulation",
"reason": "scheduler_cron.py is itself an entry point script that directly uses handler functions — not a module violating encapsulation",
"pattern": "Handler imported directly"
},
{
"file": "apps/handlers/schedule/task_registry.py",
"standard": "unused_function",
"reason": "Public API functions used by tests (test_task_registry.py) and available for external callers",
"pattern": "get_task_by_id|get_pending_tasks"
},
{
"file": "apps/plugins/__init__.py",
"standard": "unused_function",
@@ -107,6 +65,18 @@
"reason": "Authorized cross-branch wake_branch import (DPLAN-0204 §2.8 path A). ai_mail exposes wake_branch only via its handler — ai_mail's own modules and @trigger import it identically; no module entry point exists to use. Root fix (an ai_mail module wrapper) is tracked separately.",
"pattern": "Handler imported directly"
},
{
"file": "apps/handlers/schedule/telegram_notifier.py",
"standard": "encapsulation",
"reason": "Authorized cross-branch import (TDPLAN-0008 contract). daemon emits lifecycle pings via skills telegram notifier — the only send path; no module wrapper exists.",
"pattern": "Handler imported directly"
},
{
"file": "apps/handlers/schedule/telegram_notifier.py",
"standard": "handlers",
"reason": "Authorized cross-branch import (TDPLAN-0008 contract). send_telegram_notification lives in @skills handler — daemon's notifier wraps it fail-soft.",
"pattern": "Cross-handler imports"
},
{
"file": "apps/modules/run.py",
"standard": "introspection",
@@ -142,6 +112,54 @@
"standard": "architecture",
"reason": "Scheduler plugin — autodiscovered by plugins/__init__.py discover_plugins(). Lives in apps/plugins/ by design, not a module or handler.",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_timer_install.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_run_module.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_run_module.py",
"standard": "documentation",
"reason": "Test methods are self-documenting via descriptive names — pytest convention",
"pattern": "missing docstrings"
},
{
"file": "tests/test_scheduler_bot.py",
"standard": "architecture",
"reason": "Test file — lives in tests/ by convention, not in 3-layer apps/ structure",
"pattern": "File not in standard 3-layer structure"
},
{
"file": "tests/test_scheduler_bot.py",
"standard": "encapsulation",
"reason": "Test file imports handlers directly to unit-test them — standard pytest pattern",
"pattern": "Handler imported directly"
},
{
"file": "apps/modules/queue.py",
"standard": "introspection",
"reason": "Bare 'queue' renders the operational Rich table (td-47). Introspection gate would break the primary use case — users expect the job table, not module metadata.",
"pattern": "no-args gate"
},
{
"file": "apps/modules/timer_install.py",
"standard": "introspection",
"reason": "install-timer/uninstall-timer are action commands — no-args IS the action (install). Introspection gate would break primary use case (same pattern as run.py).",
"pattern": "no-args gate"
},
{
"file": "apps/modules/timer_install.py",
"standard": "modules",
"reason": "Installer module — mkdir for ~/.config/systemd/user/ is the core purpose. Direct file ops are inherent to the install function.",
"pattern": "direct file operations"
}
],
"notes": {
+52 -5
View File
@@ -94,6 +94,8 @@ drone @daemon actions list # Action registry
drone @daemon actions <id> on/off # Toggle action
drone @daemon actions set reminder 7d "msg" --to @branch
drone @daemon actions set schedule @branch "prompt" daily 04:00
drone @daemon install-timer # Install + enable systemd user timer
drone @daemon uninstall-timer # Stop + remove systemd user timer
```
Each module accepts `--help` for module-specific usage:
@@ -108,11 +110,56 @@ drone @daemon <command> --help
| Module | Description | Status |
|--------|-------------|--------|
| `update` | Status digest of DAEMON activity | *(partial)* — reads inbox/sessions but data_loader paths return empty |
| `schedule` | Fire-and-forget scheduled follow-ups and task management | Operational |
| `queue` | Unified job queue view — Rich table or `--json` (frozen schema for @skills bot) | Operational |
| `schedule` | *(retired)* Fire-and-forget follow-ups — superseded by `.daemon/schedule.json` | Retired |
| `activity_report` | Branch activity reports: `activity`, `activity-report`, `branch-health` | Operational |
| `actions` | Action registry CLI — list, toggle, info, set reminder, set schedule, migrate | Operational |
| `actions` | *(retired)* Action registry — superseded by `.daemon/schedule.json` | Retired |
| `scheduler_ops` | Scheduler cron operations facade for scheduler_cron.py | Operational |
| `wakeup_ops` | Wake-up cron operations facade for daemon_wakeup.py | Operational |
| `timer_install` | Idempotent systemd user timer installer for daemon scheduler | Operational |
| `run` | Decentralized scheduler tick: discover .daemon/ jobs, fire due ones | Operational |
---
## Scheduling Jobs
Each branch owns its schedule at `src/aipass/<branch>/.daemon/schedule.json`. The daemon discovers and fires — branches define their own jobs.
### Job file schema
```json
{
"version": 1,
"branch": "@<branch>",
"jobs": [
{
"id": "my-job",
"enabled": true,
"schedule": { "type": "interval", "interval_minutes": 30 },
"wake": { "fresh": true, "model": "haiku" },
"prompt": "Do something, then STOP."
}
]
}
```
### Schedule types
| Type | Fields | Due when |
|------|--------|----------|
| `interval` | `interval_minutes: N` | Elapsed >= N since last_run. Fires immediately if never run. |
| `daily` | `time: "HH:MM"` | Within +/-15 min of target time, once per day. |
| `hourly` | `time: "M"` (minute) | Within +/-15 min of target minute, once per hour. |
| `once` | `due_date: "YYYY-MM-DD"` | Date <= today, then marks completed. |
### Wake options
- `fresh` (bool) — start a fresh Claude session (true) or resume (false)
- `model` (string, optional) — `"haiku"` or `"sonnet"` recommended for light wakes
### Staggering
No native offset field. To stagger jobs, seed different `last_run` values in `daemon_json/daemon_runstate.json`.
---
@@ -160,11 +207,11 @@ drone @daemon <command> --help
## Test Suite
- **448 tests** across 19 test files
- 8/8 modules covered, 43/51 public functions tested
- **300 tests** across 15 test files
- 8/8 modules covered, 30/36 public functions tested
- Seedgo audit: **100%** across all standards
*Last Updated: 2026-04-07*
*Last Updated: 2026-06-29*
---
[← Back to AIPass](../../../README.md)
+7 -4
View File
@@ -24,7 +24,7 @@ from aipass.prax.apps.modules.logger import system_logger as logger
# Console
from aipass.cli.apps.modules import console, error
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run
from aipass.daemon.apps.modules import update, schedule, activity_report, actions, run, timer_install, queue
def _header(text):
@@ -46,7 +46,7 @@ def get_modules() -> List[Any]:
List of module objects with handle_command function
"""
modules = []
for mod in [update, schedule, activity_report, actions, run]:
for mod in [update, schedule, activity_report, actions, run, timer_install, queue]:
if hasattr(mod, "handle_command"):
modules.append(mod)
return modules
@@ -136,12 +136,15 @@ def print_help(modules: List[Any]):
# Show actual routable commands, not module names
_COMMAND_HELP = [
("update", "Returns digest of DAEMON activity for check-ins."),
("schedule", "CLI interface for fire-and-forget scheduled follow-ups."),
("queue", "Unified job queue view (--json for frozen schema)."),
("activity", "Quick 24-hour activity summary."),
("activity-report", "Full detailed activity report (--json for raw)."),
("branch-health", "Single branch deep dive (e.g., branch-health DAEMON)."),
("actions", "CLI interface for the numbered action registry."),
("run", "One scheduler tick: discover .daemon/ jobs, fire due ones."),
("install-timer", "Install + enable daemon-tick systemd user timer (~2 min)."),
("uninstall-timer", "Stop + remove daemon-tick systemd user timer."),
("schedule", "(retired) Use .daemon/schedule.json — see run --help."),
("actions", "(retired) Use .daemon/schedule.json — see run --help."),
]
for cmd_name, desc in _COMMAND_HELP:
@@ -1,550 +0,0 @@
# =================== AIPass ====================
# Name: actions_registry.py
# Description: Numbered Action Registry
# Version: 1.0.0
# Created: 2026-03-02
# Modified: 2026-03-02
# =============================================
"""
Numbered Action Registry — DPLAN-043
Central registry for all scheduled actions. Each action gets a sequential
numeric ID (0001, 0002, ...) and can be individually toggled on/off.
Replaces the old all-or-nothing daemon + kill switch model with granular
per-action control.
Action types:
- plugin: Backed by a plugin file in apps/plugins/ (migrated from existing system)
- schedule: Custom recurring action (dispatches via wake.py)
- reminder: One-shot action that auto-completes after firing
"""
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
# logger imported from aipass.prax
# Paths
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
REGISTRY_FILE = _DAEMON_ROOT / "daemon_json" / "actions_registry.json"
PLUGINS_DIR = _DAEMON_ROOT / "apps" / "plugins"
def _empty_registry() -> dict:
"""Return a fresh empty registry structure (avoids shared mutable state)."""
return {"version": 1, "next_id": 1, "actions": []}
# =============================================
# STORAGE
# =============================================
def load_registry() -> dict:
"""Load the actions registry from disk. Returns empty registry if missing."""
if not REGISTRY_FILE.exists():
return _empty_registry().copy()
try:
with open(REGISTRY_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if "actions" not in data:
data["actions"] = []
if "next_id" not in data:
data["next_id"] = 1
return data
except (json.JSONDecodeError, OSError) as e:
logger.error("[actions_registry] Failed to load: %s", e)
return _empty_registry().copy()
def save_registry(data: dict) -> bool:
"""Save the actions registry to disk. Returns True on success."""
try:
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(REGISTRY_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
return True
except OSError as e:
logger.error("[actions_registry] Failed to save: %s", e)
return False
# =============================================
# ID GENERATION
# =============================================
def _get_next_id(registry: dict) -> str:
"""Get next sequential ID as 4-digit string. Advances next_id."""
next_num = registry.get("next_id", 1)
action_id = f"{next_num:04d}"
registry["next_id"] = next_num + 1
return action_id
# =============================================
# CRUD OPERATIONS
# =============================================
def create_action(
name: str,
action_type: str,
schedule_type: str,
target_branch: str = "",
prompt: str = "",
time: Optional[str] = None,
interval_minutes: Optional[int] = None,
due_date: Optional[str] = None,
fresh: bool = True,
max_turns: int = 50,
enabled: bool = True,
self_dispatch: bool = False,
plugin_file: Optional[str] = None,
) -> dict:
"""
Create a new action and save to registry.
Args:
name: Human-readable action name (e.g., "daily_audit")
action_type: "plugin" | "schedule" | "reminder"
schedule_type: "daily" | "hourly" | "interval" | "once"
target_branch: Target branch email (e.g., "@seedgo")
prompt: What the dispatched agent should do
time: For daily: "HH:MM", for hourly: "MM"
interval_minutes: For interval schedule type
due_date: For reminder (once) type, ISO date string
fresh: Start fresh session (True) or resume (False)
max_turns: Max agent turns
enabled: Active by default
self_dispatch: Plugin handles its own dispatch
plugin_file: Plugin filename (without .py) for plugin-backed actions
Returns:
The created action dict
"""
registry = load_registry()
action_id = _get_next_id(registry)
action = {
"id": action_id,
"name": name,
"type": action_type,
"schedule_type": schedule_type,
"time": time,
"interval_minutes": interval_minutes,
"due_date": due_date,
"target_branch": target_branch,
"prompt": prompt,
"fresh": fresh,
"max_turns": max_turns,
"enabled": enabled,
"self_dispatch": self_dispatch,
"plugin_file": plugin_file,
"last_run": None,
"next_run": None,
"created": datetime.now().isoformat(),
"completed": None,
}
registry["actions"].append(action)
save_registry(registry)
json_handler.log_operation("action_registry_modified", {"action": name})
logger.info("[actions_registry] Created action %s: %s (%s)", action_id, name, action_type)
return action
def get_action(action_id: str) -> Optional[dict]:
"""Get a single action by ID. Returns None if not found."""
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
return action
return None
def list_actions(include_completed: bool = False) -> list:
"""
List all actions.
Args:
include_completed: If True, include completed reminders.
Returns:
List of action dicts.
"""
registry = load_registry()
actions = registry["actions"]
if not include_completed:
actions = [a for a in actions if a.get("completed") is None]
return actions
def toggle_action(action_id: str, enabled: bool) -> bool:
"""Toggle an action on or off. Returns True if found and updated."""
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
action["enabled"] = enabled
save_registry(registry)
state = "enabled" if enabled else "disabled"
logger.info("[actions_registry] Action %s %s: %s", action_id, state, action["name"])
return True
return False
def delete_action(action_id: str) -> bool:
"""Delete an action by ID. Returns True if found and removed."""
registry = load_registry()
original_len = len(registry["actions"])
registry["actions"] = [a for a in registry["actions"] if a["id"] != action_id]
if len(registry["actions"]) < original_len:
save_registry(registry)
logger.info("[actions_registry] Deleted action %s", action_id)
return True
return False
def update_last_run(action_id: str, timestamp: Optional[str] = None) -> bool:
"""Update last_run timestamp for an action. Returns True if found."""
if timestamp is None:
timestamp = datetime.now().isoformat()
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
action["last_run"] = timestamp
action["next_run"] = calc_next_run(action)
save_registry(registry)
return True
return False
def mark_reminder_completed(action_id: str) -> bool:
"""Mark a reminder as completed (one-shot). Returns True if found."""
registry = load_registry()
for action in registry["actions"]:
if action["id"] == action_id:
action["completed"] = datetime.now().isoformat()
action["enabled"] = False
save_registry(registry)
logger.info("[actions_registry] Reminder %s completed: %s", action_id, action["name"])
return True
return False
# =============================================
# DUE CHECKING
# =============================================
def _already_ran_today(action: dict, now: datetime) -> bool:
"""Check if a daily action already ran today."""
last_run = action.get("last_run")
if not last_run:
return False
try:
last_dt = datetime.fromisoformat(last_run)
return last_dt.date() == now.date()
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Daily last_run parse failed: %s", e)
return False
def _already_ran_this_hour(action: dict, now: datetime) -> bool:
"""Check if an hourly action already ran this hour."""
last_run = action.get("last_run")
if not last_run:
return False
try:
last_dt = datetime.fromisoformat(last_run)
return last_dt.hour == now.hour and last_dt.date() == now.date()
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Hourly last_run parse failed: %s", e)
return False
def _is_daily_due(action: dict, now: datetime) -> bool:
"""Check if a daily action is due."""
target_time = action.get("time", "00:00")
try:
target_h, target_m = map(int, target_time.split(":"))
except (ValueError, AttributeError) as e:
logger.info("[actions_registry] Daily time parse failed for %r: %s", target_time, e)
return False
current_minutes = now.hour * 60 + now.minute
target_minutes = target_h * 60 + target_m
minutes_diff = abs(current_minutes - target_minutes)
minutes_diff = min(minutes_diff, 1440 - minutes_diff)
if minutes_diff > 15:
return False
return not _already_ran_today(action, now)
def _is_hourly_due(action: dict, now: datetime) -> bool:
"""Check if an hourly action is due."""
target_m_str = action.get("time", "0")
try:
target_m = int(target_m_str)
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Hourly time parse failed for %r: %s", target_m_str, e)
return False
minutes_diff = abs(now.minute - target_m)
minutes_diff = min(minutes_diff, 60 - minutes_diff)
if minutes_diff > 15:
return False
return not _already_ran_this_hour(action, now)
def _is_interval_due(action: dict, now: datetime) -> bool:
"""Check if an interval action is due."""
interval = action.get("interval_minutes", 60)
last_run = action.get("last_run")
if not last_run:
return True
try:
last_dt = datetime.fromisoformat(last_run)
elapsed = (now - last_dt).total_seconds() / 60
return elapsed >= interval
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Interval last_run parse failed: %s", e)
return True
def _is_once_due(action: dict, now: datetime) -> bool:
"""Check if a one-shot reminder action is due."""
due_date = action.get("due_date")
if not due_date:
return False
try:
due_dt = (
datetime.fromisoformat(due_date).date()
if "T" in due_date
else datetime.strptime(due_date, "%Y-%m-%d").date()
)
return now.date() >= due_dt
except (ValueError, TypeError) as e:
logger.info("[actions_registry] Once due_date parse failed for %r: %s", due_date, e)
return False
def is_action_due(action: dict) -> bool:
"""
Check if an action should run now.
For daily: matches current hour:minute, hasn't run today
For hourly: matches current minute, hasn't run this hour
For interval: enough time has elapsed since last run
For once (reminder): due_date <= today, not completed
"""
if not action.get("enabled", False):
return False
if action.get("completed"):
return False
now = datetime.now()
schedule_type = action.get("schedule_type", "")
_due_checkers = {
"daily": _is_daily_due,
"hourly": _is_hourly_due,
"interval": _is_interval_due,
"once": _is_once_due,
}
checker = _due_checkers.get(schedule_type)
if checker is None:
return False
return checker(action, now)
def _calc_next_daily(action: dict, now: datetime) -> Optional[str]:
"""Calculate next run for a daily action."""
target_time = action.get("time", "00:00")
try:
target_h, target_m = map(int, target_time.split(":"))
except (ValueError, AttributeError) as e:
logger.info("[actions_registry] calc_next_run daily time parse failed: %s", e)
return None
next_dt = now.replace(hour=target_h, minute=target_m, second=0, microsecond=0)
if next_dt <= now:
next_dt += timedelta(days=1)
return next_dt.isoformat()
def _calc_next_hourly(action: dict, now: datetime) -> Optional[str]:
"""Calculate next run for an hourly action."""
target_m_str = action.get("time", "0")
try:
target_m = int(target_m_str)
except (ValueError, TypeError) as e:
logger.info("[actions_registry] calc_next_run hourly time parse failed: %s", e)
return None
next_dt = now.replace(minute=target_m, second=0, microsecond=0)
if next_dt <= now:
next_dt += timedelta(hours=1)
return next_dt.isoformat()
def _calc_next_interval(action: dict, now: datetime) -> Optional[str]:
"""Calculate next run for an interval action."""
interval = action.get("interval_minutes", 60)
last_run = action.get("last_run")
if not last_run:
return now.isoformat()
try:
last_dt = datetime.fromisoformat(last_run)
return (last_dt + timedelta(minutes=interval)).isoformat()
except (ValueError, TypeError) as e:
logger.info("[actions_registry] calc_next_run interval last_run parse failed: %s", e)
return now.isoformat()
def calc_next_run(action: dict) -> Optional[str]:
"""Calculate the next run time for an action. Returns ISO string or None."""
now = datetime.now()
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
return _calc_next_daily(action, now)
if schedule_type == "hourly":
return _calc_next_hourly(action, now)
if schedule_type == "interval":
return _calc_next_interval(action, now)
if schedule_type == "once":
due_date = action.get("due_date")
if due_date and not action.get("completed"):
return due_date
return None
return None
def _next_due_interval(action: dict) -> str:
"""Human-readable next due string for interval actions."""
interval = action.get("interval_minutes", 60)
last_run = action.get("last_run")
if not last_run:
return "now"
try:
last_dt = datetime.fromisoformat(last_run)
next_dt = last_dt + timedelta(minutes=interval)
if next_dt <= datetime.now():
return "now"
return next_dt.strftime("%H:%M")
except (ValueError, TypeError) as e:
logger.info("[actions_registry] next_due_str interval last_run parse failed: %s", e)
return "now"
def next_due_str(action: dict) -> str:
"""Human-readable next due string for display."""
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
return f"daily @ {action.get('time', '00:00')}"
if schedule_type == "hourly":
m = action.get("time", "0")
return f"hourly @ :{int(m):02d}"
if schedule_type == "interval":
return _next_due_interval(action)
if schedule_type == "once":
return action.get("due_date", "unknown")
return "unknown"
# =============================================
# PLUGIN MIGRATION
# =============================================
def migrate_plugins() -> int:
"""
Scan plugins/ directory and auto-register any plugins not yet in the registry.
Maps PLUGIN_CONFIG fields to action fields. Preserves last_run timestamps
from .last_run.json.
Returns:
Number of newly migrated plugins.
"""
registry = load_registry()
existing_plugins = {a["plugin_file"] for a in registry["actions"] if a.get("plugin_file")}
# Load last_run data for timestamp preservation
last_run_file = PLUGINS_DIR / ".last_run.json"
last_run_map = {}
if last_run_file.exists():
try:
last_run_map = json.loads(last_run_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.warning("[actions_registry] Failed to load last_run.json: %s", e)
# Discover plugins
migrated = 0
for plugin_path in sorted(PLUGINS_DIR.glob("*.py")):
if plugin_path.name.startswith("_"):
continue
plugin_name = plugin_path.stem
if plugin_name in existing_plugins:
continue
# Import plugin to read PLUGIN_CONFIG
try:
import importlib
# Use absolute package path for plugin import
spec_name = f"aipass.daemon.apps.plugins.{plugin_name}"
module = importlib.import_module(spec_name)
if not hasattr(module, "PLUGIN_CONFIG"):
continue
config = module.PLUGIN_CONFIG
except Exception as e:
logger.warning("[actions_registry] Failed to import plugin %s: %s", plugin_name, e)
continue
# Map PLUGIN_CONFIG to action fields
action_id = _get_next_id(registry)
action = {
"id": action_id,
"name": config.get("name", plugin_name),
"type": "plugin",
"schedule_type": config.get("schedule", "interval"),
"time": config.get("time"),
"interval_minutes": config.get("interval_minutes"),
"due_date": None,
"target_branch": config.get("branch", ""),
"prompt": config.get("prompt", ""),
"fresh": config.get("fresh", True),
"max_turns": config.get("max_turns", 50),
"enabled": config.get("enabled", False),
"self_dispatch": config.get("self_dispatch", False),
"plugin_file": plugin_name,
"last_run": last_run_map.get(config.get("name", plugin_name)),
"next_run": None,
"created": datetime.now().isoformat(),
"completed": None,
}
# Calculate next_run from last_run
action["next_run"] = calc_next_run(action)
registry["actions"].append(action)
migrated += 1
logger.info("[actions_registry] Migrated plugin: %s -> action %s", plugin_name, action_id)
if migrated > 0:
save_registry(registry)
logger.info("[actions_registry] Migration complete: %d plugin(s) migrated", migrated)
return migrated
@@ -2,10 +2,12 @@
# META DATA HEADER
# Name: __init__.py - Schedule Handlers Package
# Date: 2026-02-04
# Version: 1.0.0
# Version: 2.0.0
# Category: daemon/handlers/schedule
#
# CHANGELOG (Max 5 entries):
# - v2.0.0 (2026-06-25): task_registry archived (TDPLAN-0008); package
# now exposes runstate + discovery for the live .daemon/ scheduler.
# - v1.0.0 (2026-02-04): Initial package setup
#
# CODE STANDARDS:
@@ -14,25 +16,26 @@
# =============================================
"""
Schedule handlers for daemon's scheduled follow-ups system.
Schedule handlers for daemon's decentralized .daemon/ scheduler.
task_registry (fire-and-forget follow-ups) has been archived — superseded
by the per-branch .daemon/schedule.json model (DPLAN-0204).
"""
from aipass.daemon.apps.handlers.schedule.task_registry import (
load_tasks,
save_tasks,
create_task,
delete_task,
get_due_tasks,
mark_completed,
parse_due_date,
from aipass.daemon.apps.handlers.schedule.runstate import (
load_runstate,
save_runstate,
update_job_runstate,
is_job_due,
job_key,
)
from aipass.daemon.apps.handlers.schedule.discovery import discover_jobs
__all__ = [
"load_tasks",
"save_tasks",
"create_task",
"delete_task",
"get_due_tasks",
"mark_completed",
"parse_due_date",
"load_runstate",
"save_runstate",
"update_job_runstate",
"is_job_due",
"job_key",
"discover_jobs",
]
@@ -250,7 +250,7 @@ def update_job_runstate(
schedule: dict,
timestamp: Optional[str] = None,
) -> None:
"""Update last_run and next_run for a job after firing."""
"""Update runstate for a job after successful firing."""
if timestamp is None:
timestamp = datetime.now().isoformat()
@@ -258,6 +258,9 @@ def update_job_runstate(
entry = runstate.setdefault("jobs", {}).setdefault(key, {})
entry["last_run"] = timestamp
entry["next_run"] = _calc_next_run(schedule, timestamp)
entry["last_status"] = "success"
entry["last_success_at"] = timestamp
entry["last_error"] = None
if schedule.get("type") == "once":
entry["completed"] = timestamp
@@ -265,6 +268,28 @@ def update_job_runstate(
json_handler.log_operation("update_job_runstate", {"key": key})
def record_job_failure(
runstate: dict,
owner: str,
job_id: str,
error_msg: str,
status: str = "failed",
timestamp: Optional[str] = None,
) -> None:
"""Record a failed job firing in runstate."""
if timestamp is None:
timestamp = datetime.now().isoformat()
key = job_key(owner, job_id)
entry = runstate.setdefault("jobs", {}).setdefault(key, {})
entry["last_run"] = timestamp
entry["last_status"] = status
entry["last_failure_at"] = timestamp
entry["last_error"] = error_msg[:500]
json_handler.log_operation("record_job_failure", {"key": key, "status": status})
def prune_orphans(runstate: dict, active_keys: set) -> int:
"""Remove runstate entries for jobs that no longer exist. Returns count pruned."""
jobs = runstate.get("jobs", {})
@@ -1,588 +0,0 @@
# =================== AIPass ====================
# Name: task_registry.py
# Description: DAEMON Scheduled Tasks Registry
# Version: 1.0.0
# Created: 2026-02-04
# Modified: 2026-02-04
# =============================================
"""
Handler for scheduled task storage and operations.
Fire-and-forget follow-up system for DAEMON.
Tasks are stored in daemon_json/schedule.json and processed
when their due date arrives.
"""
import json
import uuid
from pathlib import Path
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
import re
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
SCHEDULE_JSON_PATH = _DAEMON_ROOT / "daemon_json" / "schedule.json"
DEFAULT_SCHEDULE_DATA: Dict[str, Any] = {"tasks": []}
# =============================================
# JSON FILE OPERATIONS
# =============================================
def _ensure_json_exists() -> None:
"""Ensure schedule.json exists, create with defaults if missing."""
SCHEDULE_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
if not SCHEDULE_JSON_PATH.exists():
with open(SCHEDULE_JSON_PATH, "w", encoding="utf-8") as f:
json.dump(DEFAULT_SCHEDULE_DATA, f, indent=2, ensure_ascii=False)
def ensure_lock_dir() -> Dict[str, Any]:
"""Ensure the daemon_json directory exists for lock files.
Returns:
Dict with 'path' (str) of the lock file directory.
"""
lock_dir = SCHEDULE_JSON_PATH.parent
lock_dir.mkdir(parents=True, exist_ok=True)
return {"path": str(lock_dir)}
def load_tasks() -> List[Dict[str, Any]]:
"""
Load all tasks from schedule.json.
Returns:
List of task dictionaries
"""
_ensure_json_exists()
try:
with open(SCHEDULE_JSON_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("tasks", [])
except (json.JSONDecodeError, IOError) as e:
logger.error("[task_registry] Failed to load schedule.json: %s", e)
return []
def save_tasks(tasks: List[Dict[str, Any]]) -> bool:
"""
Save tasks to schedule.json.
Args:
tasks: List of task dictionaries to save
Returns:
True if successful, False otherwise
"""
_ensure_json_exists()
try:
data = {"tasks": tasks}
with open(SCHEDULE_JSON_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return True
except IOError as e:
logger.error("[task_registry] Failed to save schedule.json: %s", e)
return False
# =============================================
# DATE PARSING
# =============================================
def parse_due_date(date_str: str) -> str:
"""
Parse various date formats to ISO 8601 date string.
Supports:
- "7d" -> 7 days from now
- "1w" -> 1 week from now
- "2w" -> 2 weeks from now
- "2026-02-11" -> exact date (ISO 8601)
Args:
date_str: Date string in supported format
Returns:
ISO 8601 date string (YYYY-MM-DD)
Raises:
ValueError: If date format is invalid
"""
date_str = date_str.strip()
today = datetime.now().date()
# Check for relative day format: "7d", "14d", etc.
day_match = re.match(r"^(\d+)d$", date_str, re.IGNORECASE)
if day_match:
days = int(day_match.group(1))
future_date = today + timedelta(days=days)
return future_date.isoformat()
# Check for relative week format: "1w", "2w", etc.
week_match = re.match(r"^(\d+)w$", date_str, re.IGNORECASE)
if week_match:
weeks = int(week_match.group(1))
future_date = today + timedelta(weeks=weeks)
return future_date.isoformat()
# Check for ISO 8601 date format: "2026-02-11"
iso_match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", date_str)
if iso_match:
try:
# Validate it's a real date
year = int(iso_match.group(1))
month = int(iso_match.group(2))
day = int(iso_match.group(3))
parsed_date = datetime(year, month, day).date()
return parsed_date.isoformat()
except ValueError as e:
raise ValueError(f"Invalid date: {date_str}") from e
raise ValueError(f"Invalid date format: '{date_str}'. Use '7d' (days), '1w' (weeks), or 'YYYY-MM-DD' (ISO date)")
# =============================================
# TASK OPERATIONS
# =============================================
def _generate_task_id() -> str:
"""Generate 16-character UUID for task ID."""
return uuid.uuid4().hex[:16]
def create_task(task: str, due_date: str, recipient: str, message: str) -> Dict[str, Any]:
"""
Create a new scheduled task.
Args:
task: Brief description of the task/follow-up
due_date: When to trigger (supports "7d", "1w", "YYYY-MM-DD")
recipient: Target branch (e.g., "@devpulse")
message: Message to deliver when due
Returns:
Created task dictionary
Raises:
ValueError: If due_date format is invalid
"""
json_handler.log_operation("task_created")
parsed_due = parse_due_date(due_date)
new_task: Dict[str, Any] = {
"id": _generate_task_id(),
"created": datetime.now().date().isoformat(),
"due_date": parsed_due,
"task": task,
"recipient": recipient,
"message": message,
"status": "pending",
}
tasks = load_tasks()
tasks.append(new_task)
save_tasks(tasks)
return new_task
def delete_task(task_id: str) -> bool:
"""
Delete a task by ID.
Args:
task_id: 8-character task ID
Returns:
True if task was found and deleted, False otherwise
"""
tasks = load_tasks()
original_count = len(tasks)
tasks = [t for t in tasks if t.get("id") != task_id]
if len(tasks) < original_count:
save_tasks(tasks)
return True
return False
def get_due_tasks() -> List[Dict[str, Any]]:
"""
Get all tasks that are due (due_date <= today).
Only returns tasks with status 'pending' - excludes 'dispatching' and 'completed'.
Returns:
List of tasks that are due for processing
"""
tasks = load_tasks()
today = datetime.now().date().isoformat()
due_tasks = [t for t in tasks if t.get("status") == "pending" and t.get("due_date", "") <= today]
return due_tasks
def mark_dispatching(task_id: str) -> bool:
"""
Mark a task as currently being dispatched.
Prevents re-dispatch while email is being sent.
Args:
task_id: 8-character task ID
Returns:
True if task was found and marked, False otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
task["status"] = "dispatching"
task["dispatch_started"] = datetime.now().isoformat()
save_tasks(tasks)
return True
return False
def mark_pending(task_id: str) -> bool:
"""
Reset a task to pending status (for retry after failed dispatch).
Args:
task_id: 8-character task ID
Returns:
True if task was found and reset, False otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
task["status"] = "pending"
task.pop("dispatch_started", None)
save_tasks(tasks)
return True
return False
def _is_stale_dispatch(started: str, cutoff: datetime) -> bool:
"""Check if a dispatch_started timestamp is older than the cutoff."""
try:
start_time = datetime.fromisoformat(started)
return start_time < cutoff
except ValueError as e:
logger.warning("[task_registry] Invalid dispatch_started timestamp, resetting task: %s", e)
return True
def recover_stale_dispatches(max_age_minutes: int = 5) -> int:
"""
Reset tasks stuck in 'dispatching' status for too long.
Called before processing to recover from crashed dispatches.
Args:
max_age_minutes: Maximum time a task can be in dispatching status
Returns:
Number of tasks recovered
"""
tasks = load_tasks()
recovered = 0
cutoff = datetime.now() - timedelta(minutes=max_age_minutes)
for task in tasks:
if task.get("status") != "dispatching":
continue
started = task.get("dispatch_started")
if not started:
continue
if _is_stale_dispatch(started, cutoff):
task["status"] = "pending"
task.pop("dispatch_started", None)
recovered += 1
if recovered:
save_tasks(tasks)
return recovered
def mark_completed(task_id: str) -> bool:
"""
Mark a task as completed.
Args:
task_id: 8-character task ID
Returns:
True if task was found and marked, False otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
task["status"] = "completed"
task["completed_date"] = datetime.now().date().isoformat()
save_tasks(tasks)
return True
return False
def get_task_by_id(task_id: str) -> Optional[Dict[str, Any]]:
"""
Get a single task by ID.
Args:
task_id: 8-character task ID
Returns:
Task dictionary if found, None otherwise
"""
tasks = load_tasks()
for task in tasks:
if task.get("id") == task_id:
return task
return None
def get_pending_tasks() -> List[Dict[str, Any]]:
"""
Get all pending tasks (not yet due or completed).
Returns:
List of pending tasks
"""
tasks = load_tasks()
return [t for t in tasks if t.get("status") == "pending"]
# =============================================
# BATCH PROCESSING
# =============================================
def _safe_mark_pending(task_id: str) -> None:
"""Best-effort reset a task to pending, logging on failure."""
try:
mark_pending(task_id)
except Exception as pending_err:
logger.error("[task_registry] Failed to reset task %s to pending: %s", task_id[:8], pending_err)
def process_due_tasks_batch(
send_email_fn=None,
stale_max_age: int = 5,
) -> Dict[str, Any]:
"""
Process all due tasks: recover stale, dispatch emails, track results.
This is the implementation logic for batch task processing.
The module layer handles display; this handler returns raw data.
Args:
send_email_fn: Callable to send email (to_branch, subject, message, ...).
If None, email dispatch is skipped.
stale_max_age: Maximum minutes before a dispatching task is considered stale.
Returns:
Dict with keys: due, success, failed, recovered, errors (list of str),
processed_tasks (list of dicts with id, recipient, task, status).
"""
import time
results: Dict[str, Any] = {
"due": 0,
"success": 0,
"failed": 0,
"recovered": 0,
"errors": [],
"processed_tasks": [],
}
# Recover any stale dispatches
try:
recovered = recover_stale_dispatches(max_age_minutes=stale_max_age)
results["recovered"] = recovered
except Exception as e:
logger.warning("[task_registry] Stale dispatch recovery failed: %s", e)
results["errors"].append(f"Stale recovery: {e}")
# Get due tasks
try:
due_tasks = get_due_tasks()
except Exception as e:
logger.error("[task_registry] Failed to load due tasks: %s", e)
results["errors"].append(f"Load tasks: {e}")
return results
results["due"] = len(due_tasks)
if not due_tasks:
return results
for task in due_tasks:
task_id = task.get("id", "")
recipient = task.get("recipient", "")
task_desc = task.get("task", "")
message = task.get("message", "")
task_result = {
"id": task_id,
"recipient": recipient,
"task": task_desc,
"status": "pending",
}
# Mark as dispatching (prevents re-dispatch)
try:
mark_dispatching(task_id)
except Exception as e:
logger.error("[task_registry] Failed to mark task %s as dispatching: %s", task_id[:8], e)
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
results["failed"] += 1
task_result["status"] = "error"
task_result["error"] = str(e)
results["processed_tasks"].append(task_result)
continue
# Build email body
email_body = f"{task_desc}"
if message:
email_body += f"\n\nDetails:\n{message}"
# Send the email
if send_email_fn is None:
mark_pending(task_id)
results["failed"] += 1
task_result["status"] = "skipped"
task_result["error"] = "email function not available"
results["errors"].append(f"Email unavailable for {task_id[:8]}")
results["processed_tasks"].append(task_result)
continue
try:
email_sent = send_email_fn(
to_branch=recipient,
subject=f"[SCHEDULED] {task_desc}",
message=email_body,
from_branch="@daemon",
auto_execute=True,
reply_to="@devpulse",
)
if email_sent:
mark_completed(task_id)
results["success"] += 1
task_result["status"] = "sent"
else:
mark_pending(task_id)
results["failed"] += 1
task_result["status"] = "failed"
task_result["error"] = "email send returned False"
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
except Exception as e:
logger.error("[task_registry] Email dispatch error for task %s: %s", task_id[:8], e)
_safe_mark_pending(task_id)
results["failed"] += 1
task_result["status"] = "error"
task_result["error"] = str(e)
results["errors"].append(f"Email error {task_id[:8]}: {e}")
results["processed_tasks"].append(task_result)
# Small delay between dispatches (prevents thundering herd)
time.sleep(1.0)
return results
# =============================================
# MAIN - Testing
# =============================================
if __name__ == "__main__":
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
console = Console()
console.print()
console.print(Panel.fit("[bold cyan]TASK REGISTRY - Handler Test[/bold cyan]", border_style="bright_blue"))
console.print()
# Test date parsing
console.print("[yellow]Testing date parsing:[/yellow]")
test_dates = ["7d", "1w", "2w", "2026-03-15"]
for d in test_dates:
try:
result = parse_due_date(d)
console.print(f" {d} -> {result}")
except ValueError as e:
logger.warning("Date parse test failed for %s: %s", d, e)
console.print(f" {d} -> [red]ERROR: {e}[/red]")
# Test invalid date
try:
parse_due_date("invalid")
except ValueError as e:
logger.info("Expected parse failure for 'invalid': %s", e)
console.print(f" invalid -> [green]Correctly raised: {e}[/green]")
console.print()
console.print("[yellow]Testing task creation:[/yellow]")
# Create a test task
test_task = create_task(
task="Test backup health check",
due_date="7d",
recipient="@devpulse",
message="Please verify backup systems are healthy",
)
console.print(f" Created task: {test_task['id']}")
console.print(f" Due: {test_task['due_date']}")
# Show all tasks
console.print()
console.print("[yellow]Current tasks:[/yellow]")
all_tasks = load_tasks()
table = Table(show_header=True)
table.add_column("ID", style="cyan")
table.add_column("Task", style="white")
table.add_column("Due", style="yellow")
table.add_column("Status", style="green")
for t in all_tasks:
table.add_row(t.get("id", "?"), t.get("task", "?")[:30], t.get("due_date", "?"), t.get("status", "?"))
console.print(table)
console.print()
console.print(f"[dim]Schedule file: {SCHEDULE_JSON_PATH}[/dim]")
console.print()
@@ -0,0 +1,53 @@
# =================== AIPass ====================
# Name: telegram_notifier.py
# Description: Scheduler lifecycle notifications via Telegram
# Version: 3.0.0
# Created: 2026-02-15
# Modified: 2026-06-25
# =============================================
"""
Scheduler lifecycle notifications — emit running/complete/failed pings
via the @skills telegram notifier (secret slug: telegram/scheduler).
Fail-soft: if the secret is missing or the send fails, returns False
and never raises. The tick MUST keep firing jobs regardless.
"""
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler # noqa: F401
def _send(message: str) -> bool:
"""Send via skills notifier, fail-soft.
Cross-branch import authorized by TDPLAN-0008 contract —
daemon emits lifecycle pings through the skills telegram notifier.
"""
try:
from aipass.skills.lib.telegram.apps.handlers.notifier import ( # noqa: E501
send_telegram_notification,
)
return send_telegram_notification(message)
except Exception as e:
logger.info("[telegram_notifier] Send failed (non-fatal): %s", e)
return False
def notify_triggered(owner: str, job_id: str) -> bool:
"""Emit 'running' ping when a job fires."""
json_handler.log_operation("notify_triggered", {"owner": owner, "job_id": job_id})
return _send(f"\U0001f535 {owner}/{job_id} running")
def notify_complete(owner: str, job_id: str, summary: str) -> bool:
"""Emit 'complete' ping after successful dispatch."""
json_handler.log_operation("notify_complete", {"owner": owner, "job_id": job_id})
return _send(f"✅ {owner}/{job_id} dispatched\n{summary}")
def notify_error(owner: str, job_id: str, error: str) -> bool:
"""Emit 'failed' ping on dispatch failure."""
json_handler.log_operation("notify_error", {"owner": owner, "job_id": job_id})
return _send(f"❌ {owner}/{job_id} FAILED\n{error}")
+25 -534
View File
@@ -1,560 +1,51 @@
# =================== AIPass ====================
# Name: actions.py
# Description: Action Registry CLI Module
# Version: 1.0.0
# Description: Action Registry CLI Module (RETIRED)
# Version: 2.0.0
# Created: 2026-03-02
# Modified: 2026-03-02
# Modified: 2026-06-25
# =============================================
"""
CLI interface for the numbered action registry.
RETIRED — the numbered action registry CLI.
Superseded by the decentralized .daemon/schedule.json model (DPLAN-0204).
Author jobs directly in a branch's .daemon/schedule.json file.
See: drone @daemon run --help (full schema + schedule types)
"""
# =============================================
# IMPORTS
# =============================================
import sys
from typing import List
from aipass.prax import logger
from aipass.cli.apps.modules import console, error as cli_error
from aipass.daemon.apps.handlers.actions.actions_registry import (
list_actions,
get_action,
toggle_action,
delete_action,
create_action,
migrate_plugins,
next_due_str,
)
from aipass.cli.apps.modules import console
from aipass.daemon.apps.handlers.json import json_handler
def _header(text):
console.print(f"\n[bold cyan]{'=' * 70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'=' * 70}[/bold cyan]")
def _success(text):
console.print(f"[green]OK:[/green] {text}")
def _error(text):
cli_error(text)
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "actions"
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
"""Display retirement notice."""
console.print()
console.print("[bold cyan]actions Module[/bold cyan]")
console.print("[bold cyan]actions Module[/bold cyan] [yellow](RETIRED)[/yellow]")
console.print()
console.print("[dim]CLI interface for the numbered action registry (DPLAN-043)[/dim]")
console.print("[dim]This CLI has been retired. Jobs now live in per-branch .daemon/schedule.json files.[/dim]")
console.print("[dim]Run [bold]drone @daemon run --help[/bold] for the schema and schedule types.[/dim]")
console.print("[dim]Run [bold]drone @daemon queue[/bold] to see the unified job queue.[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/actions/")
console.print(
" [cyan]*[/cyan] actions_registry.py"
" [dim](list_actions, get_action,"
" toggle_action, delete_action, create_action,"
" migrate_plugins, next_due_str — registry CRUD)[/dim]"
)
console.print()
# =============================================
# OUTPUT FORMATTING
# =============================================
def _format_schedule(action: dict) -> str:
"""Build schedule display string for an action."""
schedule_type = action.get("schedule_type", "")
if schedule_type == "daily":
return f"daily @ {action.get('time', '??:??')}"
if schedule_type == "hourly":
m = action.get("time", "0")
return f"hourly @ :{int(m):02d}"
if schedule_type == "interval":
mins = action.get("interval_minutes", 0)
if mins >= 60:
return f"every {mins // 60}h"
return f"every {mins}m"
if schedule_type == "once":
return f"once: {action.get('due_date', '?')}"
return schedule_type
def _print_actions_table(actions: list) -> None:
"""Display formatted action list as a table."""
console.print()
_header("Action Registry")
console.print()
if not actions:
console.print("[dim]No actions registered. Run 'actions migrate' to import plugins.[/dim]")
console.print()
return
# Header row
console.print(f" {'ID':<6} {'ON':<4} {'NAME':<24} {'TYPE':<10} {'TARGET':<16} {'SCHEDULE':<20} {'NEXT DUE':<16}")
console.print(" " + "-" * 96)
for action in actions:
action_id = action.get("id", "????")
enabled = "[green]ON[/green] " if action.get("enabled") else "[red]OFF[/red]"
name = action.get("name", "")[:22]
action_type = action.get("type", "")[:8]
target = action.get("target_branch", "")[:14]
schedule_str = _format_schedule(action)
next_due = next_due_str(action)
console.print(
f" {action_id:<6} {enabled:<4} {name:<24} {action_type:<10} "
f" {target:<16} {schedule_str:<20} {next_due:<16}"
)
console.print()
enabled_count = sum(1 for a in actions if a.get("enabled"))
console.print(f" [dim]Total: {len(actions)} actions ({enabled_count} enabled)[/dim]")
console.print()
def _print_action_detail(action: dict) -> None:
"""Display detailed view of a single action."""
console.print()
_header(f"Action {action['id']}: {action['name']}")
console.print()
fields = [
("ID", action.get("id")),
("Name", action.get("name")),
("Type", action.get("type")),
("Enabled", "[green]ON[/green]" if action.get("enabled") else "[red]OFF[/red]"),
("Schedule", action.get("schedule_type")),
("Time", action.get("time")),
("Interval", f"{action.get('interval_minutes')}m" if action.get("interval_minutes") else None),
("Due Date", action.get("due_date")),
("Target", action.get("target_branch")),
("Fresh", action.get("fresh")),
("Max Turns", action.get("max_turns")),
("Self Dispatch", action.get("self_dispatch")),
("Plugin File", action.get("plugin_file")),
("Last Run", action.get("last_run", "never")[:19] if action.get("last_run") else "never"),
("Next Run", next_due_str(action)),
("Created", action.get("created", "")[:19]),
("Completed", action.get("completed")),
]
for label, value in fields:
if value is None:
continue
console.print(f" [cyan]{label:<16}[/cyan] {value}")
# Show prompt (truncated for readability)
prompt = action.get("prompt", "")
if prompt:
console.print()
console.print(" [cyan]Prompt:[/cyan]")
# Show first 200 chars
display_prompt = prompt[:200]
if len(prompt) > 200:
display_prompt += "..."
for line in display_prompt.split("\n"):
console.print(f" [dim]{line}[/dim]")
console.print()
def print_help() -> None:
"""Display help using Rich formatted output."""
console.print()
_header("Actions -- Numbered Action Registry")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" drone @daemon actions list")
console.print(" drone @daemon actions <id> info")
console.print(" drone @daemon actions <id> on")
console.print(" drone @daemon actions <id> off")
console.print(' drone @daemon actions set reminder <date> "message" [--to @branch]')
console.print(' drone @daemon actions set schedule @branch "prompt" <type> [time]')
console.print(" drone @daemon actions migrate")
console.print(" drone @daemon actions delete <id>")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" list List all registered actions with status")
console.print(" <id> info Show detailed view of a single action")
console.print(" <id> on Enable an action")
console.print(" <id> off Disable an action")
console.print(" set Create a new reminder or schedule")
console.print(" migrate Import existing plugins into registry")
console.print(" delete <id> Remove an action from the registry")
console.print()
console.print("[yellow]SET REMINDER:[/yellow]")
console.print(' set reminder 2026-03-11 "Check VERA progress"')
console.print(' set reminder 7d "Follow up on PR review" --to @flow')
console.print(" [dim]Date formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
console.print()
console.print("[yellow]SET SCHEDULE:[/yellow]")
console.print(' set schedule @seedgo "Run audit" daily 04:00')
console.print(' set schedule @daemon "Heartbeat" interval 240')
console.print(' set schedule @flow "Check plans" hourly 30')
console.print(" [dim]Types: daily HH:MM, hourly MM, interval MINUTES[/dim]")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" actions list # See all actions")
console.print(" actions 0003 off # Disable action 3")
console.print(" actions 0003 on # Re-enable it")
console.print(' actions set reminder 2026-03-11 "check VERA" # One-shot reminder')
console.print()
# =============================================
# SUBCOMMAND HANDLERS
# =============================================
def _handle_list(_args: List[str]) -> bool:
"""Handle 'actions list' subcommand."""
actions = list_actions()
_print_actions_table(actions)
logger.info("[DAEMON] actions: Action list displayed")
return True
def _handle_toggle(action_id: str, enable: bool) -> bool:
"""Handle 'actions <id> on/off' subcommand."""
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return True # Error displayed
toggle_action(action_id, enable)
state = "enabled" if enable else "disabled"
_success(f"Action {action_id} ({action['name']}) {state}")
logger.info("[DAEMON] actions: Action toggled")
return True
def _handle_info(action_id: str) -> bool:
"""Handle 'actions <id> info' subcommand."""
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return True # Error displayed
_print_action_detail(action)
logger.info("[DAEMON] actions: Action info displayed")
return True
def _handle_set_reminder(args: List[str]) -> bool:
"""Handle 'actions set reminder <date> "message" [--to @branch]'."""
if len(args) < 2:
_error('Usage: actions set reminder <date> "message" [--to @branch]')
return True # Error displayed
date_str = args[0]
message = args[1]
target_branch = "@devpulse" # Default reminder target
# Parse --to flag
if "--to" in args:
to_idx = args.index("--to")
if to_idx + 1 < len(args):
target_branch = args[to_idx + 1]
# Parse date
due_date = _parse_date(date_str)
if not due_date:
_error(f"Invalid date format: {date_str}")
console.print("[dim]Valid formats: YYYY-MM-DD, 1d, 7d, 1w, 2w[/dim]")
return True # Error displayed
action = create_action(
name=message[:50],
action_type="reminder",
schedule_type="once",
target_branch=target_branch,
prompt=message,
due_date=due_date,
fresh=True,
max_turns=10,
enabled=True,
)
_success(f"Reminder created: {action['id']}")
console.print(f" [dim]Due:[/dim] {due_date}")
console.print(f" [dim]To:[/dim] {target_branch}")
console.print(f" [dim]Message:[/dim] {message[:60]}")
console.print()
logger.info("[DAEMON] actions: Reminder set")
return True
def _handle_set_schedule(args: List[str]) -> bool:
"""Handle 'actions set schedule @branch "prompt" <type> [time_spec]'."""
if len(args) < 3:
_error('Usage: actions set schedule @branch "prompt" <daily|hourly|interval> [time_spec]')
return True # Error displayed
target_branch = args[0]
prompt = args[1]
schedule_type = args[2]
time_val = None
interval_minutes = None
if schedule_type not in ("daily", "hourly", "interval"):
_error(f"Unknown schedule type: {schedule_type}")
console.print("[dim]Valid types: daily, hourly, interval[/dim]")
return True # Error displayed
if len(args) < 4:
_error(f"{schedule_type.title()} schedule requires a time/value argument")
return True # Error displayed
if schedule_type in ("daily", "hourly"):
time_val = args[3]
else:
try:
interval_minutes = int(args[3])
except ValueError:
logger.warning("Invalid interval minutes value: %s", args[3])
_error(f"Invalid interval minutes: {args[3]}")
return True # Error displayed
# Generate a name from the prompt
name = prompt[:50].replace(" ", "_").lower()
action = create_action(
name=name,
action_type="schedule",
schedule_type=schedule_type,
target_branch=target_branch,
prompt=prompt,
time=time_val,
interval_minutes=interval_minutes,
fresh=True,
max_turns=50,
enabled=True,
)
_success(f"Schedule created: {action['id']}")
console.print(f" [dim]Name:[/dim] {action['name']}")
console.print(f" [dim]Target:[/dim] {target_branch}")
console.print(f" [dim]Type:[/dim] {schedule_type}")
if time_val:
console.print(f" [dim]Time:[/dim] {time_val}")
if interval_minutes:
console.print(f" [dim]Every:[/dim] {interval_minutes} minutes")
console.print()
logger.info("[DAEMON] actions: Schedule set")
return True
def _handle_migrate(_args: List[str]) -> bool:
"""Handle 'actions migrate' -- import plugins into registry."""
console.print()
console.print("[dim]Scanning plugins/ for unregistered plugins...[/dim]")
count = migrate_plugins()
if count > 0:
_success(f"Migrated {count} plugin(s) into the action registry")
else:
console.print("[dim]All plugins already registered (or none found).[/dim]")
# Show the updated list
actions = list_actions()
_print_actions_table(actions)
logger.info("[DAEMON] actions: Plugin migration completed")
return True
def _handle_delete(args: List[str]) -> bool:
"""Handle 'actions delete <id>'."""
if not args:
_error("Action ID required: actions delete <id>")
return True # Error displayed
action_id = args[0]
action = get_action(action_id)
if action is None:
_error(f"Action not found: {action_id}")
return True # Error displayed
delete_action(action_id)
_success(f"Deleted action {action_id}: {action['name']}")
logger.info("[DAEMON] actions: Action deleted")
return True
# =============================================
# DATE PARSING
# =============================================
def _parse_date(date_str: str) -> str:
"""
Parse a date string into ISO format.
Supports: YYYY-MM-DD, 1d, 7d, 1w, 2w
Returns:
ISO date string or empty string on failure.
"""
from datetime import datetime, timedelta
date_str = date_str.strip()
# Relative dates
if date_str.endswith("d"):
try:
days = int(date_str[:-1])
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
except ValueError:
logger.warning("Invalid relative day format: %s", date_str)
return ""
elif date_str.endswith("w"):
try:
weeks = int(date_str[:-1])
return (datetime.now() + timedelta(weeks=weeks)).strftime("%Y-%m-%d")
except ValueError:
logger.warning("Invalid relative week format: %s", date_str)
return ""
# ISO date
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
logger.warning("Invalid ISO date format: %s", date_str)
return ""
# =============================================
# ORCHESTRATION
# =============================================
def _route_set_subcommand(args: List[str]) -> bool:
"""Route 'actions set reminder ...' / 'actions set schedule ...'."""
if len(args) < 2:
_error("Usage: actions set <reminder|schedule> ...")
return True # Error displayed
set_type = args[1]
if set_type == "reminder":
return _handle_set_reminder(args[2:])
if set_type == "schedule":
return _handle_set_schedule(args[2:])
_error(f"Unknown set type: {set_type}. Use 'reminder' or 'schedule'.")
return True # Error displayed
def _route_action_id(action_id: str, args: List[str]) -> bool:
"""Route 'actions <4-digit-id> [on|off|info]'."""
if len(args) < 2:
return _handle_info(action_id)
sub_action = args[1]
if sub_action == "on":
return _handle_toggle(action_id, True)
if sub_action == "off":
return _handle_toggle(action_id, False)
if sub_action == "info":
return _handle_info(action_id)
_error(f"Unknown action command: {sub_action}. Use 'on', 'off', or 'info'.")
return True # Error displayed
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle 'actions' command and route to subcommands.
Args:
command: Command name (should be 'actions')
args: Command arguments
Returns:
True if handled, False otherwise
"""
"""Handle 'actions' command — retired, shows migration notice."""
if command != "actions":
return False
try:
# No args -- introspection gate
if not args:
print_introspection()
return True
if not args:
print_introspection()
return True
# Help flag
if args[0] in ["--help", "-h", "help"]:
print_help()
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
return True
subcommand = args[0]
json_handler.log_operation("actions_command", {"subcommand": args[0] if args else "introspection"})
# Named subcommands
if subcommand == "list":
return _handle_list(args[1:])
if subcommand == "migrate":
return _handle_migrate(args[1:])
if subcommand == "delete":
return _handle_delete(args[1:])
if subcommand == "set":
return _route_set_subcommand(args)
# Check if first arg is an action ID (4-digit numeric)
if subcommand.isdigit() and len(subcommand) == 4:
return _route_action_id(subcommand, args)
_error(f"Unknown subcommand: {subcommand}")
console.print("[dim]Run 'actions --help' for available commands[/dim]")
return True # Command was handled (error displayed)
except Exception as e:
logger.error("[actions] Error in actions command: %s", e, exc_info=True)
_error(f"Error: {e}")
return True # Error displayed
# =============================================
# MAIN ENTRY
# =============================================
def main() -> None:
"""Main entry point for direct execution."""
args = sys.argv[1:]
if not args or args[0] in ["--help", "-h", "help"]:
print_help()
return
handle_command("actions", args)
if __name__ == "__main__":
main()
json_handler.log_operation("actions_command_retired", {"args": args[:2]})
logger.info("[actions] Retired CLI invoked")
print_introspection()
return True
+200
View File
@@ -0,0 +1,200 @@
# =================== AIPass ====================
# Name: queue.py
# Description: Unified job queue view (drone @daemon queue)
# Version: 1.0.0
# Created: 2026-06-25
# Modified: 2026-06-25
# =============================================
"""
Unified queue view — aggregates .daemon/schedule.json jobs joined to runstate.
Human-readable Rich table (default) or --json matching the frozen contract
consumed by @skills' scheduler bot.
"""
import json
from datetime import datetime, timezone
from typing import List, Optional
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.handlers.schedule.discovery import discover_jobs
from aipass.daemon.apps.handlers.schedule.runstate import (
load_runstate,
get_job_state,
)
HANDLED_COMMANDS = {"queue"}
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]queue Module[/bold cyan]")
console.print()
console.print("[dim]Unified job queue view — .daemon/ jobs joined to runstate[/dim]")
console.print()
console.print("[yellow]Reads:[/yellow]")
console.print(" [cyan]*[/cyan] src/aipass/*/.daemon/*.json [dim](per-branch schedule files)[/dim]")
console.print(" [cyan]*[/cyan] daemon_json/daemon_runstate.json [dim](last_run/status state)[/dim]")
console.print()
def print_help():
"""Display usage information."""
console.print("\n[bold cyan]queue — Unified Job Queue View[/bold cyan]")
console.print("\n[yellow]USAGE:[/yellow]")
console.print(" drone @daemon queue Show job queue (Rich table)")
console.print(" drone @daemon queue --json Machine-readable JSON (frozen schema)")
console.print(" drone @daemon queue --help Show this help message")
console.print()
def _schedule_human(job: dict) -> str:
"""Build human-readable schedule string."""
sched = job.get("schedule", {})
sched_type = sched.get("type", "")
if sched_type == "once":
return sched.get("due_date", "?")
if sched_type == "daily":
return f"daily @ {sched.get('time', '??:??')}"
if sched_type == "hourly":
m = sched.get("time", "0")
return f"hourly @ :{int(m):02d}"
if sched_type == "interval":
mins = sched.get("interval_minutes", 0)
if mins >= 60:
return f"every {mins // 60}h"
return f"every {mins}m"
return sched_type
def _compute_next_run(job: dict, state: dict) -> Optional[str]:
"""Determine next_run from runstate or schedule."""
if state.get("completed"):
return None
next_run = state.get("next_run")
if next_run:
return next_run
sched = job.get("schedule", {})
if sched.get("type") == "once":
due = sched.get("due_date")
if due and "T" not in due:
return f"{due}T09:00:00"
return due
return None
def _build_queue(jobs: list, runstate: dict) -> list:
"""Build unified queue entries from discovered jobs + runstate."""
entries = []
for job in jobs:
state = get_job_state(runstate, job["owner"], job["id"])
if state.get("completed"):
continue
owner = job["owner"]
if owner.startswith("@"):
pass
elif "@" in owner:
owner = f"@{owner.split('@')[0]}"
prompt = job.get("prompt", "")
flat = " ".join(prompt.split()) # collapse newlines/whitespace → single-line preview
preview = flat[:80] + "..." if len(flat) > 80 else flat
entries.append(
{
"owner": owner,
"id": job["id"],
"enabled": job.get("enabled", True),
"type": job["schedule"].get("type", ""),
"schedule_human": _schedule_human(job),
"next_run": _compute_next_run(job, state),
"last_run": state.get("last_run"),
"last_status": state.get("last_status"),
"last_error": state.get("last_error"),
"prompt_preview": preview,
"wake": job.get("wake", {}),
}
)
return entries
def _print_rich_table(entries: list) -> None:
"""Print queue as a Rich table."""
console.print()
console.print("[bold cyan]Job Queue[/bold cyan]")
console.print()
if not entries:
console.print("[dim]No jobs in queue.[/dim]")
console.print()
return
console.print(
f" {'OWNER':<14} {'ID':<20} {'ON':<4} {'TYPE':<9} {'SCHEDULE':<18} {'LAST STATUS':<12} {'NEXT RUN':<20}"
)
console.print(" " + "-" * 97)
for e in entries:
enabled = "[green]ON[/green] " if e["enabled"] else "[red]OFF[/red]"
last_status = e.get("last_status") or "-"
next_run = (e.get("next_run") or "-")[:19]
console.print(
f" {e['owner']:<14} {e['id']:<20} {enabled:<4} {e['type']:<9} "
f"{e['schedule_human']:<18} {last_status:<12} {next_run:<20}"
)
console.print()
enabled_count = sum(1 for e in entries if e["enabled"])
console.print(f" [dim]Total: {len(entries)} job(s) ({enabled_count} enabled)[/dim]")
console.print()
def _build_json_output(entries: list) -> dict:
"""Build frozen-schema JSON output for @skills consumption."""
return {
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"count": len(entries),
"jobs": entries,
}
def handle_command(command: str, args: List[str]) -> bool:
"""Handle 'queue' command from daemon CLI router."""
if command not in HANDLED_COMMANDS:
return False
if not args:
json_handler.log_operation("queue_command", {"json": False})
jobs = discover_jobs()
runstate = load_runstate()
entries = _build_queue(jobs, runstate)
_print_rich_table(entries)
logger.info("[queue] Queue displayed (%d jobs)", len(entries))
return True
if args[0] in ("--help", "-h"):
print_help()
return True
json_handler.log_operation("queue_command", {"json": "--json" in args})
jobs = discover_jobs()
runstate = load_runstate()
entries = _build_queue(jobs, runstate)
if "--json" in args:
output = _build_json_output(entries)
# soft_wrap=True + markup=False — default console.print forces width-80
# wrapping on non-TTY and parses [] markup, injecting newlines mid-string
# that corrupt machine output. These flags emit clean, parseable JSON.
console.print(json.dumps(output, indent=2), soft_wrap=True, markup=False)
else:
_print_rich_table(entries)
logger.info("[queue] Queue displayed (%d jobs)", len(entries))
return True
+73 -13
View File
@@ -27,6 +27,7 @@ from aipass.daemon.apps.handlers.schedule.runstate import (
save_runstate,
is_job_due,
update_job_runstate,
record_job_failure,
job_key,
prune_orphans,
)
@@ -69,6 +70,38 @@ def print_help():
console.print("\n[yellow]DESCRIPTION:[/yellow]")
console.print(" Sweeps src/aipass/*/.daemon/*.json for scheduled jobs,")
console.print(" evaluates due-ness, and wakes each due branch via wake_branch().")
console.print("\n[bold cyan]SCHEDULING — How to author a job:[/bold cyan]")
console.print(" [bold]File:[/bold] src/aipass/<branch>/.daemon/schedule.json")
console.print()
console.print(" [bold]Schema:[/bold]")
console.print(" {")
console.print(' "version": 1,')
console.print(' "branch": "@<branch>",')
console.print(' "jobs": [')
console.print(" {")
console.print(' "id": "my-job",')
console.print(' "enabled": true,')
console.print(' "schedule": { "type": "interval", "interval_minutes": 30 },')
console.print(' "wake": { "fresh": true, "model": "haiku" },')
console.print(' "prompt": "Do something, then STOP."')
console.print(" }")
console.print(" ]")
console.print(" }")
console.print()
console.print(" [bold]Schedule types:[/bold]")
console.print(" [cyan]interval[/cyan] interval_minutes: N")
console.print(" [dim]Fires when elapsed >= N since last_run. Fires immediately if never run.[/dim]")
console.print(" [cyan]daily[/cyan] time: HH:MM")
console.print(" [dim]+/-15 min window, once per day.[/dim]")
console.print(" [cyan]hourly[/cyan] time: M [dim](minute of hour)[/dim]")
console.print(" [dim]+/-15 min window, once per hour.[/dim]")
console.print(" [cyan]once[/cyan] due_date: YYYY-MM-DD")
console.print(" [dim]Fires when date <= today, then marks completed.[/dim]")
console.print()
console.print(" [bold]wake options:[/bold] fresh (bool), model (haiku/sonnet — use light models)")
console.print()
console.print(" [bold]Staggering:[/bold] No native offset field. Seed different last_run values")
console.print(" in daemon_json/daemon_runstate.json to offset first-fire timing.")
console.print()
@@ -80,18 +113,36 @@ def _log(message: str) -> None:
console.print(f"[{timestamp}] {message}")
def _fire_job(job: dict) -> bool:
"""Fire a single job via direct wake_branch import (DPLAN-0204 path A)."""
def _should_notify(job: dict) -> bool:
"""Check if this job should emit telegram notifications."""
return job.get("notify", True)
def _fire_job(job: dict) -> tuple:
"""Fire a single job via direct wake_branch import (DPLAN-0204 path A).
Returns (ok: bool, error_msg: str).
"""
# Cross-branch handler import authorized by DPLAN-0204 §2.8
from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch # noqa: E402
from aipass.daemon.apps.handlers.schedule.telegram_notifier import (
notify_triggered,
notify_complete,
notify_error,
)
owner = job["owner"]
job_id = job["id"]
prompt = job["prompt"]
wake = job.get("wake", {})
fresh = wake.get("fresh", True)
model = wake.get("model")
notify = _should_notify(job)
_log(f"FIRE: {owner}/{job['id']} -> wake_branch({owner}, fresh={fresh}, model={model})")
_log(f"FIRE: {owner}/{job_id} -> wake_branch({owner}, fresh={fresh}, model={model})")
if notify:
notify_triggered(owner, job_id)
try:
status, ok = wake_branch(
@@ -103,16 +154,24 @@ def _fire_job(job: dict) -> bool:
model=model,
)
if ok:
_log(f"OK: {owner}/{job['id']} — {status.summary}")
logger.info("[run] Fired %s/%s successfully", owner, job["id"])
_log(f"OK: {owner}/{job_id} — {status.summary}")
logger.info("[run] Fired %s/%s successfully", owner, job_id)
if notify:
notify_complete(owner, job_id, status.summary)
return True, ""
else:
_log(f"FAIL: {owner}/{job['id']} — {status.summary}")
logger.warning("[run] Failed to fire %s/%s: %s", owner, job["id"], status.summary)
return ok
msg = status.summary
_log(f"FAIL: {owner}/{job_id} — {msg}")
logger.warning("[run] Failed to fire %s/%s: %s", owner, job_id, msg)
if notify:
notify_error(owner, job_id, msg)
return False, msg
except Exception as e:
logger.error("[run] Exception firing %s/%s: %s", owner, job["id"], e)
_log(f"ERROR: {owner}/{job['id']} — {e}")
return False
logger.error("[run] Exception firing %s/%s: %s", owner, job_id, e)
_log(f"ERROR: {owner}/{job_id} — {e}")
if notify:
notify_error(owner, job_id, str(e))
return False, str(e)
def run_tick(dry_run: bool = False) -> dict:
@@ -177,13 +236,14 @@ def run_tick(dry_run: bool = False) -> dict:
# Step 4: Fire due jobs
for job in due_jobs:
ok = _fire_job(job)
ok, error_msg = _fire_job(job)
if ok:
results["fired"] += 1
update_job_runstate(runstate, job["owner"], job["id"], job["schedule"])
save_runstate(runstate)
else:
results["failed"] += 1
record_job_failure(runstate, job["owner"], job["id"], error_msg)
save_runstate(runstate)
if job != due_jobs[-1]:
time.sleep(1.0)
+25 -410
View File
@@ -1,436 +1,51 @@
# =================== AIPass ====================
# Name: schedule.py
# Description: DAEMON Scheduled Follow-ups Module
# Version: 1.0.0
# Description: DAEMON Scheduled Follow-ups Module (RETIRED)
# Version: 2.0.0
# Created: 2026-02-04
# Modified: 2026-02-04
# Modified: 2026-06-25
# =============================================
"""
CLI interface for fire-and-forget scheduled follow-ups.
RETIRED — the old fire-and-forget follow-up CLI.
Superseded by the decentralized .daemon/schedule.json model (DPLAN-0204).
Author jobs directly in a branch's .daemon/schedule.json file.
See: drone @daemon run --help (full schema + schedule types)
"""
# =============================================
# IMPORTS
# =============================================
import sys
import argparse
import subprocess
from pathlib import Path
from typing import List
from aipass.prax import logger
from aipass.cli.apps.modules import console, error as cli_error
from aipass.cli.apps.modules import console
from aipass.daemon.apps.handlers.json import json_handler
from aipass.daemon.apps.handlers.schedule.task_registry import (
load_tasks,
create_task,
delete_task,
parse_due_date,
process_due_tasks_batch,
ensure_lock_dir,
)
# File lock for single-instance execution
try:
from filelock import FileLock, Timeout
FILELOCK_AVAILABLE = True
except ImportError:
FILELOCK_AVAILABLE = False
FileLock = None # type: ignore[assignment,misc]
Timeout = None # type: ignore[assignment,misc]
logger.info("Optional: filelock not available")
def _header(text):
console.print(f"\n[bold cyan]{'=' * 70}[/bold cyan]")
console.print(f"[bold cyan] {text}[/bold cyan]")
console.print(f"[bold cyan]{'=' * 70}[/bold cyan]")
def _success(text):
console.print(f"[green]OK:[/green] {text}")
def _error(text):
cli_error(text)
def _send_email_via_drone(
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
):
"""Send email via drone @ai_mail send subprocess."""
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
if auto_execute:
cmd.append("--dispatch")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=DRONE_SUBPROCESS_TIMEOUT)
return result.returncode == 0
except (subprocess.SubprocessError, OSError) as e:
logger.warning("Drone email subprocess failed: %s", e)
return False
AI_MAIL_AVAILABLE = True
send_email_direct = _send_email_via_drone
# =============================================
# CONSTANTS
# =============================================
MODULE_NAME = "schedule"
# Constants
DRONE_SUBPROCESS_TIMEOUT = 15 # seconds
STALE_DISPATCH_MAX_AGE = 5 # minutes
LOCK_ACQUIRE_TIMEOUT = 0 # seconds (non-blocking)
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
"""Display retirement notice."""
console.print()
console.print("[bold cyan]schedule Module[/bold cyan]")
console.print("[bold cyan]schedule Module[/bold cyan] [yellow](RETIRED)[/yellow]")
console.print()
console.print("[dim]CLI interface for fire-and-forget scheduled follow-ups[/dim]")
console.print("[dim]This CLI has been retired. Jobs now live in per-branch .daemon/schedule.json files.[/dim]")
console.print("[dim]Run [bold]drone @daemon run --help[/bold] for the schema and schedule types.[/dim]")
console.print("[dim]Run [bold]drone @daemon queue[/bold] to see the unified job queue.[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/schedule/")
console.print(
" [cyan]*[/cyan] task_registry.py"
" [dim](load_tasks, create_task, delete_task,"
" get_due_tasks, mark_completed, parse_due_date,"
" mark_dispatching, mark_pending, recover_stale_dispatches,"
" process_due_tasks_batch, ensure_lock_dir"
" — task CRUD and processing)[/dim]"
)
console.print()
_DAEMON_ROOT = Path(__file__).resolve().parents[3] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
# =============================================
# OUTPUT FORMATTING
# =============================================
def _print_task_list(tasks: List[dict]) -> None:
"""Print formatted task list to console."""
console.print()
_header("Scheduled Tasks")
console.print()
pending_tasks = [t for t in tasks if t.get("status") == "pending"]
completed_tasks = [t for t in tasks if t.get("status") == "completed"]
if not pending_tasks:
console.print("[dim]No pending scheduled tasks.[/dim]")
else:
console.print("[bold cyan]PENDING TASKS[/bold cyan]")
console.print(f"{'ID':<10} {'DUE':<20} {'TO':<15} {'TASK':<40}")
console.print("-" * 85)
for task in pending_tasks:
task_id = task.get("id", "")[:8]
due = task.get("due_date", "")
recipient = task.get("recipient", "")
task_text = task.get("task", "")[:38]
console.print(f"{task_id:<10} {due:<20} {recipient:<15} {task_text:<40}")
console.print()
console.print(f"[dim]Total: {len(pending_tasks)} pending, {len(completed_tasks)} completed[/dim]")
console.print()
def _print_help() -> None:
"""Display help using Rich formatted output."""
console.print()
_header("Schedule Module - Fire-and-Forget Follow-ups")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(' drone @daemon schedule create "task" --due 7d --to @branch --message "details"')
console.print(" drone @daemon schedule list")
console.print(" drone @daemon schedule delete <id>")
console.print(" drone @daemon schedule run-due")
console.print()
console.print("[yellow]COMMANDS:[/yellow]")
console.print(" create Create a new scheduled task")
console.print(" list List all pending scheduled tasks")
console.print(" delete Delete a scheduled task by ID")
console.print(" run-due Execute all due tasks (sends emails, marks complete)")
console.print()
console.print("[yellow]CREATE OPTIONS:[/yellow]")
console.print(" --due (Required) Due date: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)")
console.print(" --to (Required) Recipient branch (e.g., @flow, @seedgo)")
console.print(" --message (Optional) Additional details for the follow-up")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" # Remind Flow to check on a plan in 7 days")
console.print(' schedule create "Check FPLAN-0290 status" --due 7d --to @flow')
console.print()
console.print(" # Follow up with Seedgo about code review in 2 weeks")
console.print(' schedule create "Code review follow-up" --due 2w --to @seedgo --message "Review PR #45"')
console.print()
console.print(" # Check all due tasks and send reminder emails")
console.print(" schedule run-due")
console.print()
# =============================================
# SUBCOMMAND HANDLERS
# =============================================
def _handle_create(args: List[str]) -> bool:
"""Handle schedule create subcommand."""
parser = argparse.ArgumentParser(prog="schedule create", add_help=False)
parser.add_argument("task", nargs="?", help="Task description")
parser.add_argument("--due", required=True, help="Due date (1d, 7d, 2w, 1m, or ISO date)")
parser.add_argument("--to", required=True, dest="recipient", help="Recipient branch")
parser.add_argument("--message", default="", help="Additional message details")
try:
parsed = parser.parse_args(args)
except SystemExit:
logger.warning("Invalid arguments for schedule create")
_error('Usage: schedule create "task" --due <date> --to @branch [--message "details"]')
return False
if not parsed.task:
_error("Task description is required")
console.print('[dim]Usage: schedule create "task" --due <date> --to @branch[/dim]')
return False
# Parse and validate due date
due_date = parse_due_date(parsed.due)
if not due_date:
_error(f"Invalid due date format: {parsed.due}")
console.print("[dim]Valid formats: 1d, 7d, 2w, 1m, or ISO date (2026-02-15)[/dim]")
return False
# Create the task
try:
new_task = create_task(task=parsed.task, due_date=due_date, recipient=parsed.recipient, message=parsed.message)
task_id = new_task.get("id", "")
_success(f"Scheduled task created: {task_id[:8]}")
console.print(f" [dim]Task:[/dim] {parsed.task}")
console.print(f" [dim]Due:[/dim] {due_date}")
console.print(f" [dim]To:[/dim] {parsed.recipient}")
if parsed.message:
console.print(f" [dim]Msg:[/dim] {parsed.message[:50]}...")
console.print()
logger.info(f"[DAEMON] Scheduled task created: {task_id[:8]} -> {parsed.recipient}")
return True
except Exception as e:
_error(f"Failed to create task: {e}")
logger.error(f"[DAEMON] Failed to create scheduled task: {e}", exc_info=True)
return False
def _handle_list(_args: List[str]) -> bool:
"""Handle schedule list subcommand."""
try:
tasks = load_tasks()
_print_task_list(tasks)
logger.info("[DAEMON] schedule: Task list displayed")
return True
except Exception as e:
_error(f"Failed to load tasks: {e}")
logger.error(f"[DAEMON] Failed to load scheduled tasks: {e}", exc_info=True)
return False
def _handle_delete(args: List[str]) -> bool:
"""Handle schedule delete subcommand."""
if not args:
_error("Task ID is required")
console.print("[dim]Usage: schedule delete <task_id>[/dim]")
return False
task_id = args[0]
try:
deleted = delete_task(task_id)
if deleted:
_success(f"Task deleted: {task_id[:8]}")
logger.info(f"[DAEMON] Scheduled task deleted: {task_id[:8]}")
return True
else:
_error(f"Task not found: {task_id[:8]}")
return False
except Exception as e:
_error(f"Failed to delete task: {e}")
logger.error(f"[DAEMON] Failed to delete scheduled task: {e}", exc_info=True)
return False
def _handle_run_due(_args: List[str]) -> bool:
"""Handle schedule run-due subcommand with single-instance lock."""
if not FILELOCK_AVAILABLE:
console.print("[dim]filelock not available, running without lock.[/dim]")
return _process_due_tasks()
lock_file = JSON_DIR / "schedule.lock"
ensure_lock_dir()
# Try to acquire lock (non-blocking)
# FILELOCK_AVAILABLE guard above ensures these are not None
lock = FileLock(lock_file, timeout=LOCK_ACQUIRE_TIMEOUT) # type: ignore[misc]
try:
with lock.acquire(timeout=LOCK_ACQUIRE_TIMEOUT):
return _process_due_tasks()
except Timeout: # type: ignore[misc]
logger.warning("Schedule run-due already in progress, skipping")
console.print("[dim]Schedule run-due already in progress, skipping.[/dim]")
return True
def _display_task_result(task_result: dict) -> None:
"""Display a single processed task result."""
task_id = task_result.get("id", "")[:8]
recipient = task_result.get("recipient", "")
task_desc = task_result.get("task", "")[:40]
status = task_result.get("status", "")
if status == "sent":
_success(f"Sent to {recipient}: {task_desc}")
logger.info(f"[DAEMON] Scheduled email sent: {task_id} -> {recipient}")
elif status == "skipped":
_error(f"ai_mail not available, cannot send to {recipient}")
elif status == "failed":
_error(f"Failed to send to {recipient}: {task_desc}")
logger.error(f"[DAEMON] Scheduled email failed: {task_id} -> {recipient}")
elif status == "error":
_error(f"Error sending to {recipient}: {task_result.get('error', '')}")
logger.error(f"[DAEMON] Scheduled email error: {task_id} -> {recipient}: {task_result.get('error', '')}")
def _process_due_tasks() -> bool:
"""Process due tasks -- delegates to handler, formats output."""
try:
# Delegate to handler for all implementation logic
email_fn = send_email_direct if AI_MAIL_AVAILABLE else None
results = process_due_tasks_batch(send_email_fn=email_fn, stale_max_age=STALE_DISPATCH_MAX_AGE)
# Display results (module responsibility)
if results["recovered"]:
console.print(f"[dim]Recovered {results['recovered']} stale dispatch(es)[/dim]")
if results["due"] == 0:
console.print("[dim]No tasks due at this time.[/dim]")
return True
console.print()
_header(f"Running {results['due']} Due Task(s)")
console.print()
for task_result in results.get("processed_tasks", []):
_display_task_result(task_result)
console.print()
console.print(f"[bold]Results:[/bold] {results['success']} sent, {results['failed']} failed")
console.print()
if results["failed"] > 0:
logger.warning("[DAEMON] %d scheduled task(s) failed to send", results["failed"])
else:
logger.info("[DAEMON] schedule: Processed due tasks")
return True # Command was handled (failures are logged, not routing errors)
except Exception as e:
_error(f"Failed to run due tasks: {e}")
logger.error(f"[DAEMON] Failed to run due tasks: {e}", exc_info=True)
return False
# =============================================
# ORCHESTRATION
# =============================================
def handle_command(command: str, args: List[str]) -> bool:
"""
Handle 'schedule' command.
Args:
command: Command name (should be 'schedule')
args: Command arguments (subcommand + subcommand args)
Returns:
True if handled, False otherwise
"""
"""Handle 'schedule' command — retired, shows migration notice."""
if command != "schedule":
return False
try:
# No args -- introspection gate
if not args:
print_introspection()
return True
if not args:
print_introspection()
return True
# Handle help flag
if args[0] in ["--help", "-h", "help"]:
_print_help()
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
return True
subcommand = args[0]
subargs = args[1:]
json_handler.log_operation("schedule_command", {"subcommand": args[0] if args else "list"})
# Route to subcommand handlers
if subcommand == "create":
return _handle_create(subargs)
if subcommand == "list":
return _handle_list(subargs)
if subcommand == "delete":
return _handle_delete(subargs)
if subcommand == "run-due":
return _handle_run_due(subargs)
_error(f"Unknown subcommand: {subcommand}")
console.print("[dim]Run 'schedule --help' for available commands[/dim]")
return False
except Exception as e:
logger.error(f"[DAEMON] Error in schedule command: {e}", exc_info=True)
_error(f"Error: {e}")
return False
# =============================================
# MAIN ENTRY
# =============================================
def main() -> None:
"""Main entry point for direct execution."""
args = sys.argv[1:]
if len(args) == 0 or args[0] in ["--help", "-h", "help"]:
_print_help()
return
# First arg is subcommand when called directly
handle_command("schedule", args)
if __name__ == "__main__":
main()
json_handler.log_operation("schedule_command_retired", {"args": args[:2]})
logger.info("[schedule] Retired CLI invoked")
print_introspection()
return True
@@ -1,133 +0,0 @@
# =================== AIPass ====================
# Name: scheduler_ops.py
# Description: Scheduler Cron Operations Module
# Version: 2.0.0
# Created: 2026-03-08
# Modified: 2026-03-10
# =============================================
"""
Scheduler operations module -- facade for cron entry point.
Provides a clean module-layer interface over handler functions
used by scheduler_cron.py.
"""
from aipass.prax import logger
from aipass.daemon.apps.handlers.json import json_handler
try:
from aipass.cli.apps.modules.display import console
except ImportError:
from rich.console import Console
console = Console()
logger.info("Optional: aipass.cli.apps.modules.display not available, using rich.console fallback")
# =============================================
# TASK REGISTRY
# =============================================
try:
from aipass.daemon.apps.handlers.schedule.task_registry import (
get_due_tasks as get_due_tasks,
mark_dispatching as mark_dispatching,
mark_completed as mark_completed,
mark_pending as mark_pending,
recover_stale_dispatches as recover_stale_dispatches,
)
TASK_REGISTRY_AVAILABLE = True
except ImportError:
TASK_REGISTRY_AVAILABLE = False
get_due_tasks = None # type: ignore[assignment]
mark_dispatching = None # type: ignore[assignment]
mark_completed = None # type: ignore[assignment]
mark_pending = None # type: ignore[assignment]
recover_stale_dispatches = None # type: ignore[assignment]
logger.info("Optional: task_registry not available")
# =============================================
# ACTION REGISTRY (DPLAN-043)
# =============================================
try:
from aipass.daemon.apps.handlers.actions.actions_registry import (
load_registry as load_registry,
is_action_due as is_action_due,
update_last_run as update_last_run,
mark_reminder_completed as mark_reminder_completed,
migrate_plugins as migrate_plugins,
next_due_str as next_due_str,
)
ACTION_REGISTRY_AVAILABLE = True
except ImportError:
ACTION_REGISTRY_AVAILABLE = False
load_registry = None # type: ignore[assignment]
is_action_due = None # type: ignore[assignment]
update_last_run = None # type: ignore[assignment]
mark_reminder_completed = None # type: ignore[assignment]
migrate_plugins = None # type: ignore[assignment]
next_due_str = None # type: ignore[assignment]
logger.info("Optional: actions_registry not available")
# =============================================
# INTROSPECTION
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]scheduler_ops Module[/bold cyan]")
console.print()
console.print("[dim]Facade for scheduler_cron.py — re-exports handler functions for cron entry point[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" handlers/schedule/")
console.print(
" [cyan]*[/cyan] task_registry.py"
" [dim](get_due_tasks, mark_dispatching,"
" mark_completed, mark_pending,"
" recover_stale_dispatches — task lifecycle)[/dim]"
)
console.print()
console.print(" handlers/actions/")
console.print(
" [cyan]*[/cyan] actions_registry.py"
" [dim](load_registry, is_action_due,"
" update_last_run, mark_reminder_completed,"
" migrate_plugins, next_due_str — action registry)[/dim]"
)
console.print()
# =============================================
# DRONE ROUTING
# =============================================
def handle_command(command: str, args: list) -> bool:
"""Handle commands routed by the entry point."""
if command == "scheduler-ops":
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
return True
json_handler.log_operation("scheduler_ops_status")
console.print()
console.print("[bold cyan]Scheduler Ops[/bold cyan] - Cron operations facade")
console.print()
console.print(" [dim]Notifications:[/dim] archived (Telegram removed)")
console.print(f" [dim]Task registry:[/dim] {TASK_REGISTRY_AVAILABLE}")
console.print(f" [dim]Action registry:[/dim] {ACTION_REGISTRY_AVAILABLE}")
console.print()
console.print("[dim]This module is a facade used by scheduler_cron.py.[/dim]")
console.print()
return True
return False
@@ -0,0 +1,183 @@
# =================== AIPass ====================
# Name: timer_install.py
# Description: Idempotent systemd user timer installer for daemon scheduler
# Version: 1.0.0
# Created: 2026-06-25
# Modified: 2026-06-25
# =============================================
"""
Timer installer — idempotent install/uninstall of daemon-tick systemd user units.
Handles 'drone @daemon install-timer' and 'drone @daemon uninstall-timer'.
Copies daemon-tick.service + daemon-tick.timer to ~/.config/systemd/user/,
reloads systemd, and enables/starts the timer.
"""
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List
from aipass.prax import logger
from aipass.cli.apps.modules import console, error
from aipass.daemon.apps.handlers.json import json_handler
_DAEMON_ROOT = Path(__file__).resolve().parents[2]
_UNIT_DIR = Path.home() / ".config" / "systemd" / "user"
_SERVICE_NAME = "daemon-tick.service"
_TIMER_NAME = "daemon-tick.timer"
HANDLED_COMMANDS = {"install-timer", "uninstall-timer"}
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]timer_install Module[/bold cyan]")
console.print()
console.print("[dim]Idempotent systemd user timer installer for daemon scheduler[/dim]")
console.print()
console.print("[yellow]Unit files:[/yellow]")
console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _SERVICE_NAME}")
console.print(f" [cyan]*[/cyan] {_DAEMON_ROOT / _TIMER_NAME}")
console.print(f" [cyan]*[/cyan] Installs to: {_UNIT_DIR}/")
console.print()
def print_help():
"""Display usage information."""
console.print("\n[bold cyan]install-timer / uninstall-timer — Daemon Scheduler Timer[/bold cyan]")
console.print("\n[yellow]USAGE:[/yellow]")
console.print(" drone @daemon install-timer Install + enable daemon-tick timer")
console.print(" drone @daemon uninstall-timer Stop + remove daemon-tick timer")
console.print(" drone @daemon install-timer --help")
console.print("\n[yellow]DESCRIPTION:[/yellow]")
console.print(" Copies daemon-tick.service and daemon-tick.timer to")
console.print(" ~/.config/systemd/user/")
console.print(" Then reloads systemd and enables+starts the timer.")
console.print(" Idempotent — safe to run multiple times.")
console.print()
def _run_systemctl(*args: str) -> bool:
"""Run a systemctl --user command. Returns True on success."""
cmd = ["systemctl", "--user", *args]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if result.returncode != 0:
logger.warning("[timer_install] systemctl --user %s failed: %s", " ".join(args), result.stderr.strip())
console.print(f" [red]FAIL:[/red] systemctl --user {' '.join(args)}")
if result.stderr.strip():
console.print(f" [dim]{result.stderr.strip()}[/dim]")
return False
return True
except FileNotFoundError:
logger.error("[timer_install] systemctl not found — systemd not available")
error("systemctl not found — systemd not available")
return False
except subprocess.TimeoutExpired:
logger.error("[timer_install] systemctl --user %s timed out", " ".join(args))
console.print(" [red]systemctl timed out[/red]")
return False
def _install() -> int:
"""Install and enable the daemon-tick timer."""
service_src = _DAEMON_ROOT / _SERVICE_NAME
timer_src = _DAEMON_ROOT / _TIMER_NAME
for src in (service_src, timer_src):
if not src.exists():
logger.error("[timer_install] Missing unit file: %s", src)
return 1
_UNIT_DIR.mkdir(parents=True, exist_ok=True)
json_handler.log_operation("install_timer", {"target": str(_UNIT_DIR)})
console.print("[bold cyan]Installing daemon-tick units...[/bold cyan]")
console.print()
for src in (service_src, timer_src):
dst = _UNIT_DIR / src.name
shutil.copy2(src, dst)
console.print(f" [green]Copied:[/green] {src.name} -> {dst}")
console.print()
console.print(" Reloading systemd user daemon...")
if not _run_systemctl("daemon-reload"):
return 1
console.print(" Enabling daemon-tick.timer...")
if not _run_systemctl("enable", _TIMER_NAME):
return 1
console.print(" Starting daemon-tick.timer...")
if not _run_systemctl("start", _TIMER_NAME):
return 1
console.print()
console.print("[bold green]daemon-tick.timer installed and active.[/bold green]")
console.print("[dim]Verify: systemctl --user list-timers | grep daemon[/dim]")
console.print()
Path.home().joinpath(".aipass").mkdir(parents=True, exist_ok=True)
logger.info("[timer_install] daemon-tick timer installed and started")
return 0
def _uninstall() -> int:
"""Stop, disable, and remove the daemon-tick timer."""
console.print("[bold cyan]Uninstalling daemon-tick units...[/bold cyan]")
console.print()
_run_systemctl("stop", _TIMER_NAME)
_run_systemctl("disable", _TIMER_NAME)
for name in (_SERVICE_NAME, _TIMER_NAME):
dst = _UNIT_DIR / name
if dst.exists():
dst.unlink()
try:
from aipass.trigger.apps.modules.core import trigger
trigger.fire("file_deleted", path=str(dst), source="timer_install")
except ImportError:
logger.info("[timer_install] Trigger module not available, skipping event fire")
except Exception as e:
logger.warning("[timer_install] Trigger fire failed (non-critical): %s", e)
console.print(f" [yellow]Removed:[/yellow] {dst}")
else:
console.print(f" [dim]Not found:[/dim] {dst}")
_run_systemctl("daemon-reload")
console.print()
console.print("[bold green]daemon-tick units removed.[/bold green]")
console.print()
logger.info("[timer_install] daemon-tick timer uninstalled")
return 0
def handle_command(command: str, args: List[str]) -> bool:
"""Handle install-timer / uninstall-timer commands."""
if command not in HANDLED_COMMANDS:
return False
if args and args[0] in ("--help", "-h"):
print_help()
return True
if command == "install-timer":
exit_code = _install()
else:
exit_code = _uninstall()
if exit_code != 0:
sys.exit(exit_code)
return True
-402
View File
@@ -1,402 +0,0 @@
# =================== AIPass ====================
# Name: scheduler_cron.py
# Description: DAEMON Scheduler Cron Trigger
# Version: 2.0.0
# Created: 2026-02-15
# Modified: 2026-03-24
# =============================================
"""
Cron trigger script for the DAEMON scheduled task system.
Called periodically by cron. Standalone script -- not imported as a module.
Flow:
1. Acquire single-instance lock
2. Recover stale dispatches
3. Process all due tasks (send emails, mark complete)
4. Process actions from registry
5. Log summary
"""
# =============================================
# IMPORTS
# =============================================
import sys
import time
import subprocess
from pathlib import Path
from datetime import datetime
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.cli.apps.modules import console
from aipass.daemon.apps.handlers.json import json_handler
# action_processor retired — new tick uses .daemon/ discovery (DPLAN-0204)
from aipass.daemon.apps.modules.run import run_tick
try:
import fcntl
except ImportError:
fcntl = None # type: ignore[assignment]
logger.info("[DAEMON] scheduler_cron: fcntl unavailable (Windows)")
# =============================================
# OPTIONAL IMPORTS (via module layer)
# =============================================
# Task registry (via module layer)
try:
from aipass.daemon.apps.modules.scheduler_ops import (
get_due_tasks,
mark_dispatching,
mark_completed,
mark_pending,
recover_stale_dispatches,
TASK_REGISTRY_AVAILABLE,
)
except ImportError as e:
logger.info(f"Optional dependency not available: scheduler_ops task registry ({e})")
TASK_REGISTRY_AVAILABLE = False
get_due_tasks = None
mark_dispatching = None
mark_completed = None
mark_pending = None
recover_stale_dispatches = None
# Email integration via drone subprocess
def _send_email_via_drone(
to_branch, subject, message, from_branch="@daemon", auto_execute=True, reply_to=None, **kwargs
):
"""Send email via drone @ai_mail send subprocess."""
cmd = ["drone", "@ai_mail", "send", to_branch, subject, message]
if auto_execute:
cmd.append("--dispatch")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
return result.returncode == 0
except (subprocess.SubprocessError, OSError) as e:
logger.warning(f"Drone email subprocess failed: {e}")
return False
AI_MAIL_AVAILABLE = True
send_email_direct = _send_email_via_drone
# =============================================
# CONSTANTS
# =============================================
_DAEMON_ROOT = Path(__file__).resolve().parents[2] # src/aipass/daemon/
JSON_DIR = _DAEMON_ROOT / "daemon_json"
EVENT_NAME = "cron-run"
LOCK_FILE = JSON_DIR / "schedule.lock"
STALE_DISPATCH_MAX_AGE = 5 # minutes
# =============================================
# LOGGING
# =============================================
def print_introspection():
"""Display module introspection info."""
console.print()
console.print("[bold cyan]scheduler_cron Module[/bold cyan]")
console.print()
console.print("[dim]Cron trigger for scheduled tasks and action registry processing[/dim]")
console.print()
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" modules/")
console.print(
" [cyan]*[/cyan] scheduler_ops.py [dim](task registry ops + action registry ops, notifications archived)[/dim]"
)
console.print()
console.print(" plugins/")
console.print(" [cyan]*[/cyan] discover_plugins [dim](plugin discovery and scheduled execution)[/dim]")
console.print()
def print_help() -> None:
"""Display usage information for scheduler_cron."""
console.print("\n[bold cyan]scheduler_cron.py - DAEMON Scheduler Cron Trigger[/bold cyan]")
console.print("\n[yellow]USAGE:[/yellow]")
console.print(" drone @daemon scheduler_cron Run the cron scheduler")
console.print(" drone @daemon scheduler_cron --help Show this help message")
console.print("\n[yellow]DESCRIPTION:[/yellow]")
console.print(" Processes due scheduled tasks and actions from the registry.")
console.print(" Intended to be called periodically by cron.")
console.print()
def log(message: str) -> None:
"""Print timestamped log line to stdout (captured by cron redirect)."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
console.print(f"[{timestamp}] {message}")
# =============================================
# TASK PROCESSING
# =============================================
def process_due_tasks() -> dict:
"""
Process all due scheduled tasks.
Recovers stale dispatches, then iterates due tasks:
mark dispatching -> send email -> mark completed or reset to pending.
Returns:
Dict with keys: due, success, failed, errors (list of error strings)
"""
results = {
"due": 0,
"success": 0,
"failed": 0,
"recovered": 0,
"errors": [],
}
if not TASK_REGISTRY_AVAILABLE:
log("WARNING: Task registry not available, skipping task processing")
return results
# Recover any stale dispatches (stuck > 5 minutes)
try:
recovered = recover_stale_dispatches(max_age_minutes=STALE_DISPATCH_MAX_AGE) # type: ignore[misc]
results["recovered"] = recovered
if recovered:
log(f"Recovered {recovered} stale dispatch(es)")
except Exception as e:
logger.warning(f"Failed to recover stale dispatches: {e}")
log(f"WARNING: Failed to recover stale dispatches: {e}")
results["errors"].append(f"Stale recovery: {e}")
# Get due tasks
try:
due_tasks = get_due_tasks() # type: ignore[misc]
except Exception as e:
logger.error(f"Failed to load due tasks: {e}")
log(f"ERROR: Failed to load due tasks: {e}")
results["errors"].append(f"Load tasks: {e}")
return results
results["due"] = len(due_tasks)
if not due_tasks:
log("No tasks due at this time.")
return results
log(f"Found {len(due_tasks)} due task(s)")
# Process each due task
for task in due_tasks:
_process_single_task(task, results)
# Small delay between dispatches (prevents thundering herd)
time.sleep(1.0)
return results
def _process_single_task(task: dict, results: dict) -> None:
"""Process a single due task: mark dispatching, send email, update results."""
task_id = task.get("id", "")
recipient = task.get("recipient", "")
task_desc = task.get("task", "")
message = task.get("message", "")
log(f"Processing: {task_id[:8]} -> {recipient}: {task_desc[:50]}")
# Mark as dispatching (prevents re-dispatch)
try:
mark_dispatching(task_id) # type: ignore[misc]
except Exception as e:
logger.warning(f"Failed to mark dispatching {task_id[:8]}: {e}")
log(f"WARNING: Failed to mark dispatching {task_id[:8]}: {e}")
results["errors"].append(f"Mark dispatching {task_id[:8]}: {e}")
results["failed"] += 1
return
# Build email body
email_body = f"{task_desc}"
if message:
email_body += f"\n\nDetails:\n{message}"
# Send the email
if not AI_MAIL_AVAILABLE:
log(f"SKIP: ai_mail not available, cannot send to {recipient}")
mark_pending(task_id) # type: ignore[misc]
results["failed"] += 1
results["errors"].append(f"ai_mail unavailable for {task_id[:8]}")
return
try:
email_sent = send_email_direct(
to_branch=recipient,
subject=f"[SCHEDULED] {task_desc}",
message=email_body,
from_branch="@daemon",
auto_execute=True,
reply_to="@devpulse",
)
if email_sent:
mark_completed(task_id) # type: ignore[misc]
log(f"OK: Sent to {recipient}: {task_desc[:40]}")
results["success"] += 1
else:
mark_pending(task_id) # type: ignore[misc]
log(f"FAIL: Email returned False for {recipient}: {task_desc[:40]}")
results["failed"] += 1
results["errors"].append(f"Email failed: {task_id[:8]} -> {recipient}")
except Exception as e:
# Reset to pending for retry on next run
try:
mark_pending(task_id) # type: ignore[misc]
except Exception as reset_err:
logger.warning(f"Best-effort reset to pending failed for {task_id[:8]}: {reset_err}")
logger.error(f"Exception sending to {recipient}: {e}")
log(f"ERROR: Exception sending to {recipient}: {e}")
results["failed"] += 1
results["errors"].append(f"Email error {task_id[:8]}: {e}")
def _next_cron_run() -> str:
"""Calculate approximate next scheduler cron run time."""
now = datetime.now()
if now.minute < 30:
next_min = 30
next_hour = now.hour
else:
next_min = 0
next_hour = (now.hour + 1) % 24
return f"{next_hour:02d}:{next_min:02d}"
# =============================================
# MAIN
# =============================================
def main() -> int:
"""
Main cron entry point.
Returns:
0 on success, 1 on error
"""
args = sys.argv[1:]
if not args:
print_introspection()
return 0
if args[0] in ["--version", "-V"]:
console.print("scheduler_cron v2.0.0")
return 0
if args[0] in ["--help", "-h"]:
print_help()
sys.exit(0)
json_handler.log_operation("cron_run")
log("=" * 60)
log("Scheduler cron triggered")
# Ensure lock directory exists
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
# Acquire single-instance lock (non-blocking, stdlib fcntl)
if fcntl is None:
log("fcntl not available (non-Unix platform), skipping lock.")
return _run_locked()
lock_fd = open(LOCK_FILE, "w", encoding="utf-8") # noqa: SIM115
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as e:
logger.warning(f"Scheduler lock acquisition failed (another instance running): {e}")
log("Another instance already running, skipping.")
lock_fd.close()
return 0
try:
return _run_locked()
finally:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
def _run_locked() -> int:
"""Execute the cron job while holding the lock."""
exit_code = 0
# Step 1: Process due tasks
try:
results = process_due_tasks()
except Exception as e:
logger.error(f"Unhandled error in process_due_tasks: {e}", exc_info=True)
log(f"CRITICAL: Unhandled error in process_due_tasks: {e}")
return 1
# Step 2: Run decentralized .daemon/ scheduler tick
tick_results = {"fired": 0, "failed": 0}
try:
tick_results = run_tick()
except Exception as e:
logger.warning(f"Unhandled error in run_tick: {e}")
log(f"WARNING: Unhandled error in scheduler tick: {e}")
# Step 3: Build summary
lines = []
# Tasks section
if results["recovered"]:
lines.append(f"Recovered {results['recovered']} stale dispatch(es)")
if results["due"] or results["success"]:
task_line = f"Tasks: {results['due']} due | {results['success']} sent"
if results["failed"]:
task_line += f" | {results['failed']} failed"
lines.append(task_line)
else:
lines.append("Tasks: none due")
# Scheduler tick section
if tick_results.get("fired") or tick_results.get("failed"):
lines.append(f"Scheduler: {tick_results.get('fired', 0)} fired, {tick_results.get('failed', 0)} failed")
else:
lines.append(f"Scheduler: {tick_results.get('discovered', 0)} discovered, none due")
# Next run
lines.append(f"Next: ~{_next_cron_run()}")
summary = "\n".join(lines)
log(f"Results: {summary}")
# Step 4: Determine exit code
if results["failed"] > 0 or results["errors"] or tick_results.get("failed", 0) > 0:
exit_code = 1
log("Scheduler cron finished")
if exit_code == 0:
logger.info("[DAEMON] scheduler_cron: Cron cycle completed successfully")
log("=" * 60)
return exit_code
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e:
# Last-resort catch -- never crash silently
logger.error(f"FATAL scheduler_cron exception: {e}", exc_info=True)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
console.print(f"[{timestamp}] FATAL: Unhandled exception: {e}")
sys.exit(1)
+22
View File
@@ -0,0 +1,22 @@
# Systemd user service for the daemon scheduler tick (oneshot).
#
# Install:
# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now daemon-tick.timer
#
# Usage:
# systemctl --user start daemon-tick # manual single tick
# systemctl --user list-timers # check timer schedule
# systemctl --user status daemon-tick # last tick result
# journalctl --user -u daemon-tick # journal entries
[Unit]
Description=AIPass Daemon Scheduler Tick — discover and fire due .daemon/ jobs
[Service]
Type=oneshot
ExecStart=%h/Projects/AIPass/.venv/bin/python3 -m aipass.daemon.apps.daemon run
WorkingDirectory=%h/Projects/AIPass
StandardOutput=append:%h/.aipass/daemon-tick.log
StandardError=append:%h/.aipass/daemon-tick.log
+20
View File
@@ -0,0 +1,20 @@
# Systemd user timer for the daemon scheduler tick (~2 min cadence).
#
# Install:
# cp daemon-tick.service daemon-tick.timer ~/.config/systemd/user/
# systemctl --user daemon-reload
# systemctl --user enable --now daemon-tick.timer
#
# Disable:
# systemctl --user disable --now daemon-tick.timer
[Unit]
Description=AIPass Daemon Scheduler Timer — fires daemon-tick.service every ~2 min
[Timer]
OnActiveSec=30s
OnUnitActiveSec=2min
Persistent=true
[Install]
WantedBy=timers.target
@@ -1,456 +0,0 @@
# =================== AIPass ====================
# Name: test_actions_module.py
# Description: Tests for the actions CLI module
# Version: 1.0.0
# Created: 2026-04-02
# Modified: 2026-04-02
# =============================================
"""Tests for the actions CLI module (apps/modules/actions.py)."""
from datetime import datetime, timedelta
from unittest.mock import patch
MODULE = "aipass.daemon.apps.modules.actions"
# =============================================
# FIXTURES
# =============================================
def _make_action(
action_id: str = "0001",
name: str = "test_action",
enabled: bool = True,
schedule_type: str = "daily",
time: str = "08:00",
action_type: str = "schedule",
target_branch: str = "@seedgo",
interval_minutes: int | None = None,
due_date: str | None = None,
prompt: str = "Run tests",
) -> dict:
"""Build a sample action dict for tests."""
action: dict = {
"id": action_id,
"name": name,
"enabled": enabled,
"schedule_type": schedule_type,
"time": time,
"type": action_type,
"target_branch": target_branch,
"prompt": prompt,
"created": "2026-03-01T00:00:00",
"last_run": None,
}
if interval_minutes is not None:
action["interval_minutes"] = interval_minutes
if due_date is not None:
action["due_date"] = due_date
return action
# =============================================
# handle_command — routing
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleCommand:
"""Tests for handle_command routing."""
def test_wrong_command_returns_false(self, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("not_actions", []) is False
def test_no_args_shows_introspection(self, _err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
result = handle_command("actions", [])
assert result is True
# introspection prints "actions Module"
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("actions Module" in c for c in calls)
def test_help_flag(self, _err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["--help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("USAGE" in c for c in calls)
def test_help_word(self, _err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["help"]) is True
@patch(f"{MODULE}.list_actions", return_value=[])
@patch(f"{MODULE}.next_due_str", return_value="--")
def test_list_subcommand(self, _nds, _la, _err, mock_console, mock_jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["list"]) is True
mock_jh.log_operation.assert_called_once()
@patch(f"{MODULE}.migrate_plugins", return_value=2)
@patch(f"{MODULE}.list_actions", return_value=[])
@patch(f"{MODULE}.next_due_str", return_value="--")
def test_migrate_subcommand(self, _nds, _la, mock_migrate, _err, _con, mock_jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["migrate"]) is True
mock_migrate.assert_called_once()
@patch(f"{MODULE}.get_action")
@patch(f"{MODULE}.delete_action")
def test_delete_with_valid_id(self, mock_del, mock_get, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_get.return_value = _make_action()
assert handle_command("actions", ["delete", "0001"]) is True
mock_del.assert_called_once_with("0001")
def test_delete_missing_id(self, mock_err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["delete"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.create_action")
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
def test_set_reminder_valid(self, _pd, mock_create, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_create.return_value = _make_action(action_id="0099")
assert handle_command("actions", ["set", "reminder", "7d", "Check PR"]) is True
mock_create.assert_called_once()
@patch(f"{MODULE}.create_action")
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
def test_set_schedule_valid(self, _pd, mock_create, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_create.return_value = _make_action(action_id="0088")
assert handle_command("actions", ["set", "schedule", "@seedgo", "Run audit", "daily", "04:00"]) is True
mock_create.assert_called_once()
@patch(f"{MODULE}.get_action")
@patch(f"{MODULE}.next_due_str", return_value="--")
def test_action_id_routes(self, _nds, mock_get, _err, _con, _jh):
from aipass.daemon.apps.modules.actions import handle_command
mock_get.return_value = _make_action(action_id="0003")
assert handle_command("actions", ["0003", "info"]) is True
mock_get.assert_called_with("0003")
def test_unknown_subcommand(self, mock_err, mock_console, _jh):
from aipass.daemon.apps.modules.actions import handle_command
assert handle_command("actions", ["foobar"]) is True
mock_err.assert_called()
# =============================================
# _handle_toggle
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleToggle:
@patch(f"{MODULE}.toggle_action")
@patch(f"{MODULE}.get_action")
def test_enable_success(self, mock_get, mock_toggle, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_toggle
mock_get.return_value = _make_action()
assert _handle_toggle("0001", True) is True
mock_toggle.assert_called_once_with("0001", True)
@patch(f"{MODULE}.toggle_action")
@patch(f"{MODULE}.get_action")
def test_disable_success(self, mock_get, mock_toggle, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_toggle
mock_get.return_value = _make_action()
assert _handle_toggle("0001", False) is True
mock_toggle.assert_called_once_with("0001", False)
@patch(f"{MODULE}.get_action", return_value=None)
def test_not_found(self, _get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_toggle
assert _handle_toggle("9999", True) is True
mock_err.assert_called()
# =============================================
# _handle_info
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleInfo:
@patch(f"{MODULE}.next_due_str", return_value="--")
@patch(f"{MODULE}.get_action")
def test_info_success(self, mock_get, _nds, _err, mock_console):
from aipass.daemon.apps.modules.actions import _handle_info
mock_get.return_value = _make_action()
assert _handle_info("0001") is True
# Should print detail header containing the action name
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("test_action" in c for c in calls)
@patch(f"{MODULE}.get_action", return_value=None)
def test_info_not_found(self, _get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_info
assert _handle_info("9999") is True
mock_err.assert_called()
# =============================================
# _handle_set_reminder
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleSetReminder:
def test_missing_args(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_reminder
assert _handle_set_reminder(["7d"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.create_action")
@patch(f"{MODULE}._parse_date", return_value="2026-04-09")
def test_with_to_flag(self, _pd, mock_create, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_reminder
mock_create.return_value = _make_action(action_id="0050")
assert _handle_set_reminder(["7d", "Follow up", "--to", "@flow"]) is True
call_kwargs = mock_create.call_args[1]
assert call_kwargs["target_branch"] == "@flow"
@patch(f"{MODULE}._parse_date", return_value="")
def test_invalid_date(self, _pd, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_reminder
assert _handle_set_reminder(["xyz", "Some msg"]) is True
mock_err.assert_called()
# =============================================
# _handle_set_schedule
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestHandleSetSchedule:
def test_invalid_type(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
assert _handle_set_schedule(["@branch", "prompt", "weekly"]) is True
mock_err.assert_called()
def test_missing_time_arg(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
assert _handle_set_schedule(["@branch", "prompt", "daily"]) is True
mock_err.assert_called()
def test_interval_non_numeric(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
assert _handle_set_schedule(["@b", "prompt", "interval", "abc"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.create_action")
def test_daily_success(self, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
mock_create.return_value = _make_action(action_id="0070")
assert _handle_set_schedule(["@seedgo", "Run audit", "daily", "04:00"]) is True
kw = mock_create.call_args[1]
assert kw["schedule_type"] == "daily"
assert kw["time"] == "04:00"
@patch(f"{MODULE}.create_action")
def test_hourly_success(self, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
mock_create.return_value = _make_action(action_id="0071")
assert _handle_set_schedule(["@flow", "Check plans", "hourly", "30"]) is True
kw = mock_create.call_args[1]
assert kw["schedule_type"] == "hourly"
@patch(f"{MODULE}.create_action")
def test_interval_success(self, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_set_schedule
mock_create.return_value = _make_action(action_id="0072")
assert _handle_set_schedule(["@daemon", "Heartbeat", "interval", "240"]) is True
kw = mock_create.call_args[1]
assert kw["interval_minutes"] == 240
# =============================================
# _handle_delete
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestHandleDelete:
def test_no_args(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_delete
assert _handle_delete([]) is True
mock_err.assert_called()
@patch(f"{MODULE}.get_action", return_value=None)
def test_not_found(self, _get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _handle_delete
assert _handle_delete(["9999"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.delete_action")
@patch(f"{MODULE}.get_action")
def test_success(self, mock_get, mock_del, _err, _con):
from aipass.daemon.apps.modules.actions import _handle_delete
mock_get.return_value = _make_action(action_id="0005")
assert _handle_delete(["0005"]) is True
mock_del.assert_called_once_with("0005")
# =============================================
# _parse_date
# =============================================
@patch(f"{MODULE}.logger")
class TestParseDate:
def test_relative_days(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
result = _parse_date("7d")
expected = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
assert result == expected
def test_relative_weeks(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
result = _parse_date("2w")
expected = (datetime.now() + timedelta(weeks=2)).strftime("%Y-%m-%d")
assert result == expected
def test_iso_format(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("2026-04-15") == "2026-04-15"
def test_invalid_format(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("not-a-date") == ""
def test_invalid_relative_day(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("xd") == ""
def test_invalid_relative_week(self, _log):
from aipass.daemon.apps.modules.actions import _parse_date
assert _parse_date("xw") == ""
# =============================================
# _format_schedule
# =============================================
class TestFormatSchedule:
def test_daily(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "daily", "time": "08:00"}) == "daily @ 08:00"
def test_hourly(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "hourly", "time": "30"}) == "hourly @ :30"
def test_interval_minutes(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "interval", "interval_minutes": 45}) == "every 45m"
def test_interval_hours(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "interval", "interval_minutes": 120}) == "every 2h"
def test_once(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "once", "due_date": "2026-04-10"}) == "once: 2026-04-10"
def test_unknown_type(self):
from aipass.daemon.apps.modules.actions import _format_schedule
assert _format_schedule({"schedule_type": "custom"}) == "custom"
# =============================================
# _route_set_subcommand / _route_action_id
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
class TestRouting:
def test_route_set_too_few_args(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_set_subcommand
assert _route_set_subcommand(["set"]) is True
mock_err.assert_called()
def test_route_set_unknown_type(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_set_subcommand
assert _route_set_subcommand(["set", "bogus"]) is True
mock_err.assert_called()
@patch(f"{MODULE}.get_action", return_value=None)
def test_route_action_id_no_sub_defaults_to_info(self, mock_get, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_action_id
assert _route_action_id("0001", ["0001"]) is True
mock_get.assert_called_with("0001")
@patch(f"{MODULE}.get_action")
@patch(f"{MODULE}.toggle_action")
def test_route_action_id_on(self, mock_toggle, mock_get, _err, _con):
from aipass.daemon.apps.modules.actions import _route_action_id
mock_get.return_value = _make_action()
assert _route_action_id("0001", ["0001", "on"]) is True
mock_toggle.assert_called_once_with("0001", True)
def test_route_action_id_unknown_sub(self, mock_err, _con):
from aipass.daemon.apps.modules.actions import _route_action_id
assert _route_action_id("0001", ["0001", "banana"]) is True
mock_err.assert_called()
@@ -1,360 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_actions_registry.py - Action Registry Tests
# Date: 2026-03-02
# Version: 1.1.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.1.0 (2026-03-07): Adapted for AIPass public repo
# * Removed sys.path manipulation, uses package imports
# - v1.0.0 (2026-03-02): Initial creation - DPLAN-043 tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Temp dir isolation (no writes to real registry)
# =============================================
"""Tests for the action registry handler."""
import json
from datetime import datetime, timedelta
import pytest
from aipass.daemon.apps.handlers.actions import actions_registry as _reg_mod
create_action = _reg_mod.create_action
get_action = _reg_mod.get_action
list_actions = _reg_mod.list_actions
toggle_action = _reg_mod.toggle_action
delete_action = _reg_mod.delete_action
update_last_run = _reg_mod.update_last_run
mark_reminder_completed = _reg_mod.mark_reminder_completed
is_action_due = _reg_mod.is_action_due
calc_next_run = _reg_mod.calc_next_run
next_due_str = _reg_mod.next_due_str
@pytest.fixture(autouse=True)
def clean_registry(tmp_path):
"""Isolate REGISTRY_FILE to a temp dir for every test."""
test_registry = tmp_path / "actions_registry.json"
original = _reg_mod.REGISTRY_FILE
_reg_mod.REGISTRY_FILE = test_registry
yield test_registry
_reg_mod.REGISTRY_FILE = original
# =============================================
# CRUD TESTS
# =============================================
class TestCreate:
def test_create_action_basic(self, clean_registry):
"""Create a simple schedule action and verify fields."""
action = create_action(
name="test_audit",
action_type="schedule",
schedule_type="daily",
target_branch="@seedgo",
prompt="Run audit",
time="04:00",
fresh=True,
max_turns=20,
)
assert action["id"] == "0001"
assert action["name"] == "test_audit"
assert action["type"] == "schedule"
assert action["schedule_type"] == "daily"
assert action["time"] == "04:00"
assert action["target_branch"] == "@seedgo"
assert action["enabled"] is True
assert action["last_run"] is None
assert action["completed"] is None
def test_create_sequential_ids(self, clean_registry):
"""IDs should be sequential: 0001, 0002, 0003..."""
a1 = create_action(name="first", action_type="schedule", schedule_type="daily")
a2 = create_action(name="second", action_type="schedule", schedule_type="daily")
a3 = create_action(name="third", action_type="reminder", schedule_type="once")
assert a1["id"] == "0001"
assert a2["id"] == "0002"
assert a3["id"] == "0003"
def test_create_reminder(self, clean_registry):
"""Create a one-shot reminder action."""
action = create_action(
name="Check VERA progress",
action_type="reminder",
schedule_type="once",
target_branch="@devpulse",
prompt="Check VERA progress",
due_date="2026-03-11",
)
assert action["type"] == "reminder"
assert action["schedule_type"] == "once"
assert action["due_date"] == "2026-03-11"
def test_create_persists_to_json(self, clean_registry):
"""Action should be persisted to the JSON file."""
create_action(name="persisted", action_type="schedule", schedule_type="daily")
data = json.loads(clean_registry.read_text())
assert len(data["actions"]) == 1
assert data["actions"][0]["name"] == "persisted"
assert data["next_id"] == 2
class TestGet:
def test_get_existing(self, clean_registry):
"""Get an action by ID."""
create_action(name="findme", action_type="schedule", schedule_type="daily")
action = get_action("0001")
assert action is not None
assert action["name"] == "findme"
def test_get_missing(self, clean_registry):
"""Get returns None for nonexistent ID."""
assert get_action("9999") is None
class TestList:
def test_list_all(self, clean_registry):
"""List returns all non-completed actions."""
create_action(name="a", action_type="schedule", schedule_type="daily")
create_action(name="b", action_type="schedule", schedule_type="hourly")
actions = list_actions()
assert len(actions) == 2
def test_list_excludes_completed(self, clean_registry):
"""Completed reminders should be excluded by default."""
create_action(name="done", action_type="reminder", schedule_type="once", due_date="2026-01-01")
mark_reminder_completed("0001")
assert len(list_actions()) == 0
assert len(list_actions(include_completed=True)) == 1
class TestToggle:
def test_toggle_off(self, clean_registry):
"""Toggle an action off."""
create_action(name="toggleme", action_type="schedule", schedule_type="daily")
assert toggle_action("0001", False) is True
action = get_action("0001")
assert action is not None
assert action["enabled"] is False
def test_toggle_on(self, clean_registry):
"""Toggle an action back on."""
create_action(name="toggleme", action_type="schedule", schedule_type="daily", enabled=False)
assert toggle_action("0001", True) is True
action = get_action("0001")
assert action is not None
assert action["enabled"] is True
def test_toggle_missing(self, clean_registry):
"""Toggle returns False for nonexistent ID."""
assert toggle_action("9999", True) is False
class TestDelete:
def test_delete_existing(self, clean_registry):
"""Delete an action by ID."""
create_action(name="deleteme", action_type="schedule", schedule_type="daily")
assert delete_action("0001") is True
assert get_action("0001") is None
def test_delete_missing(self, clean_registry):
"""Delete returns False for nonexistent ID."""
assert delete_action("9999") is False
# =============================================
# DUE CHECKING TESTS
# =============================================
class TestIsDue:
def test_daily_due_at_correct_time(self, clean_registry):
"""Daily action is due when current time matches."""
now = datetime.now()
action = {
"enabled": True,
"completed": None,
"schedule_type": "daily",
"time": f"{now.hour:02d}:{now.minute:02d}",
"last_run": None,
}
assert is_action_due(action) is True
def test_daily_not_due_wrong_time(self, clean_registry):
"""Daily action is not due at wrong time (12 hours away from now)."""
from datetime import datetime
now = datetime.now()
# Pick a time 12 hours away — always outside the 15-min fuzzy window
far_hour = (now.hour + 12) % 24
action = {
"enabled": True,
"completed": None,
"schedule_type": "daily",
"time": f"{far_hour:02d}:00",
"last_run": None,
}
assert is_action_due(action) is False
def test_daily_not_due_already_ran_today(self, clean_registry):
"""Daily action not due if already ran today."""
now = datetime.now()
action = {
"enabled": True,
"completed": None,
"schedule_type": "daily",
"time": f"{now.hour:02d}:{now.minute:02d}",
"last_run": now.isoformat(),
}
assert is_action_due(action) is False
def test_interval_due_never_run(self, clean_registry):
"""Interval action is due if never run before."""
action = {
"enabled": True,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 60,
"last_run": None,
}
assert is_action_due(action) is True
def test_interval_due_enough_time_elapsed(self, clean_registry):
"""Interval action is due when enough time has passed."""
past = (datetime.now() - timedelta(minutes=120)).isoformat()
action = {
"enabled": True,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 60,
"last_run": past,
}
assert is_action_due(action) is True
def test_interval_not_due_too_soon(self, clean_registry):
"""Interval action is not due when too little time has passed."""
recent = (datetime.now() - timedelta(minutes=5)).isoformat()
action = {
"enabled": True,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 60,
"last_run": recent,
}
assert is_action_due(action) is False
def test_once_due_past_date(self, clean_registry):
"""Reminder is due when due_date is in the past."""
action = {
"enabled": True,
"completed": None,
"schedule_type": "once",
"due_date": "2026-01-01",
}
assert is_action_due(action) is True
def test_once_not_due_future_date(self, clean_registry):
"""Reminder is not due when due_date is in the future."""
action = {
"enabled": True,
"completed": None,
"schedule_type": "once",
"due_date": "2099-12-31",
}
assert is_action_due(action) is False
def test_disabled_never_due(self, clean_registry):
"""Disabled action is never due."""
action = {
"enabled": False,
"completed": None,
"schedule_type": "interval",
"interval_minutes": 1,
"last_run": None,
}
assert is_action_due(action) is False
def test_completed_never_due(self, clean_registry):
"""Completed action is never due."""
action = {
"enabled": True,
"completed": "2026-03-01T12:00:00",
"schedule_type": "once",
"due_date": "2026-01-01",
}
assert is_action_due(action) is False
# =============================================
# NEXT RUN TESTS
# =============================================
class TestCalcNextRun:
def test_daily_next_run(self, clean_registry):
"""Daily action calculates next run correctly."""
action = {"schedule_type": "daily", "time": "04:00", "last_run": None}
result = calc_next_run(action)
assert result is not None
assert "04:00:00" in result
def test_interval_next_run(self, clean_registry):
"""Interval action calculates next run from last_run + interval."""
last = datetime.now().isoformat()
action = {"schedule_type": "interval", "interval_minutes": 60, "last_run": last}
result = calc_next_run(action)
assert result is not None
def test_once_next_run(self, clean_registry):
"""Reminder returns due_date as next run."""
action = {"schedule_type": "once", "due_date": "2026-03-11", "completed": None}
assert calc_next_run(action) == "2026-03-11"
class TestNextDueStr:
def test_daily_str(self, clean_registry):
action = {"schedule_type": "daily", "time": "04:00"}
assert next_due_str(action) == "daily @ 04:00"
def test_hourly_str(self, clean_registry):
action = {"schedule_type": "hourly", "time": "30"}
assert next_due_str(action) == "hourly @ :30"
def test_once_str(self, clean_registry):
action = {"schedule_type": "once", "due_date": "2026-03-11"}
assert next_due_str(action) == "2026-03-11"
# =============================================
# UPDATE TESTS
# =============================================
class TestUpdateLastRun:
def test_update_last_run(self, clean_registry):
"""Update last_run sets timestamp and recalculates next_run."""
create_action(name="test", action_type="schedule", schedule_type="interval", interval_minutes=60)
ts = "2026-03-02T12:00:00"
assert update_last_run("0001", ts) is True
action = get_action("0001")
assert action is not None
assert action["last_run"] == ts
assert action["next_run"] is not None
class TestMarkCompleted:
def test_mark_reminder_completed(self, clean_registry):
"""Marking a reminder completed sets completed timestamp and disables it."""
create_action(name="reminder", action_type="reminder", schedule_type="once", due_date="2026-03-01")
assert mark_reminder_completed("0001") is True
action = get_action("0001")
assert action is not None
assert action["completed"] is not None
assert action["enabled"] is False
@@ -265,6 +265,12 @@ def test_help_preempts() -> None:
assert result == 0, "--help must return 0 before any module routing"
def test_invalid_mode_raises() -> None:
"""invalid_mode_raises: save_json with invalid_type raises ValueError."""
with pytest.raises(ValueError):
json_handler.save_json("contract_mod", "invalid_type_xyz", {"bad": True})
def test_no_args_triggers() -> None:
"""no_args_triggers: no arguments triggers introspection display."""
from aipass.daemon.apps import daemon as _daemon_mod
+10 -2
View File
@@ -1,3 +1,11 @@
# =================== AIPass ====================
# Name: test_run_module.py
# Description: Tests for the drone @daemon run module
# Version: 1.1.0
# Created: 2026-06-15
# Modified: 2026-06-25
# =============================================
"""Tests for the drone @daemon run module (decentralized scheduler tick)."""
from unittest.mock import patch
@@ -63,7 +71,7 @@ class TestRunTick:
assert results["due"] == 0
@patch("aipass.daemon.apps.modules.run.save_runstate")
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=True)
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=(True, ""))
@patch("aipass.daemon.apps.modules.run.discover_jobs")
@patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}})
def test_fires_due_job(self, mock_rs, mock_discover, mock_fire, mock_save):
@@ -84,7 +92,7 @@ class TestRunTick:
mock_save.assert_called()
@patch("aipass.daemon.apps.modules.run.save_runstate")
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=False)
@patch("aipass.daemon.apps.modules.run._fire_job", return_value=(False, "wake failed"))
@patch("aipass.daemon.apps.modules.run.discover_jobs")
@patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}})
def test_failed_fire_counted(self, mock_rs, mock_discover, mock_fire, mock_save):
@@ -1,352 +0,0 @@
# =================== AIPass ====================
# Name: test_schedule_module.py
# Description: Tests for the schedule CLI module
# Version: 1.0.0
# Created: 2026-04-03
# Modified: 2026-04-03
# =============================================
"""Tests for the schedule CLI module (apps/modules/schedule.py)."""
from unittest.mock import patch, MagicMock
MODULE = "aipass.daemon.apps.modules.schedule"
# =============================================
# handle_command -- routing basics
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestHandleCommandRouting:
"""Tests for handle_command routing."""
def test_wrong_command_returns_false(self, _log, _err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
assert handle_command("not_schedule", []) is False
def test_no_args_shows_introspection(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", [])
assert result is True
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("schedule Module" in c for c in calls)
def test_help_flag(self, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["--help"])
assert result is True
calls = [str(c) for c in mock_con.print.call_args_list]
assert any("USAGE" in c for c in calls)
def test_unknown_subcommand(self, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["foobar"])
assert result is False
mock_err.assert_called()
# =============================================
# handle_command -- list subcommand
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestListSubcommand:
"""Tests for 'schedule list' subcommand."""
@patch(f"{MODULE}.load_tasks", return_value=[])
def test_list_success(self, mock_load, _log, _err, mock_con, mock_jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["list"])
assert result is True
mock_load.assert_called_once()
@patch(f"{MODULE}.load_tasks", side_effect=RuntimeError("disk error"))
def test_list_exception(self, _load, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["list"])
assert result is False
mock_err.assert_called()
# =============================================
# handle_command -- delete subcommand
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestDeleteSubcommand:
"""Tests for 'schedule delete' subcommand."""
def test_delete_no_args_shows_error(self, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["delete"])
assert result is False
mock_err.assert_called()
@patch(f"{MODULE}.delete_task", return_value=True)
def test_delete_success(self, mock_del, _log, _err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["delete", "abc123"])
assert result is True
mock_del.assert_called_once_with("abc123")
@patch(f"{MODULE}.delete_task", return_value=False)
def test_delete_not_found(self, mock_del, _log, mock_err, _con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["delete", "abc123"])
assert result is False
mock_err.assert_called()
# =============================================
# handle_command -- run-due subcommand
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestRunDueSubcommand:
"""Tests for 'schedule run-due' subcommand."""
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 0,
"due": 0,
"success": 0,
"failed": 0,
"processed_tasks": [],
},
)
@patch(f"{MODULE}.FILELOCK_AVAILABLE", False)
def test_run_due_without_lock(self, mock_batch, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["run-due"])
assert result is True
mock_batch.assert_called_once()
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 1,
"due": 2,
"success": 1,
"failed": 1,
"processed_tasks": [
{"id": "a1", "recipient": "@flow", "task": "Check plan", "status": "sent"},
{"id": "a2", "recipient": "@seedgo", "task": "Audit", "status": "failed"},
],
},
)
@patch(f"{MODULE}.FILELOCK_AVAILABLE", False)
def test_run_due_processes_tasks(self, mock_batch, _log, _err, mock_con, _jh):
from aipass.daemon.apps.modules.schedule import handle_command
result = handle_command("schedule", ["run-due"])
assert result is True
mock_batch.assert_called_once()
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "1 sent" in calls
assert "1 failed" in calls
# =============================================
# _handle_create
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestHandleCreate:
"""Tests for _handle_create."""
@patch(f"{MODULE}.create_task", return_value={"id": "task-001"})
@patch(f"{MODULE}.parse_due_date", return_value="2026-04-10")
def test_create_valid(self, _due, mock_create, _log, _err, _con):
from aipass.daemon.apps.modules.schedule import _handle_create
result = _handle_create(["Follow up", "--due", "7d", "--to", "@flow"])
assert result is True
mock_create.assert_called_once()
def test_create_missing_task(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _handle_create
result = _handle_create(["--due", "7d", "--to", "@flow"])
assert result is False
mock_err.assert_called()
@patch(f"{MODULE}.parse_due_date", return_value=None)
def test_create_invalid_due(self, _due, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _handle_create
result = _handle_create(["Task text", "--due", "xyz", "--to", "@flow"])
assert result is False
mock_err.assert_called()
# =============================================
# _process_due_tasks
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestProcessDueTasks:
"""Tests for _process_due_tasks."""
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 0,
"due": 0,
"success": 0,
"failed": 0,
"processed_tasks": [],
},
)
def test_no_tasks_due(self, mock_batch, _log, _err, mock_con):
from aipass.daemon.apps.modules.schedule import _process_due_tasks
result = _process_due_tasks()
assert result is True
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "No tasks due" in calls
@patch(
f"{MODULE}.process_due_tasks_batch",
return_value={
"recovered": 0,
"due": 2,
"success": 1,
"failed": 1,
"processed_tasks": [
{"id": "t1", "recipient": "@flow", "task": "Check", "status": "sent"},
{"id": "t2", "recipient": "@seedgo", "task": "Audit", "status": "failed"},
],
},
)
def test_mix_sent_failed(self, mock_batch, _log, _err, mock_con):
from aipass.daemon.apps.modules.schedule import _process_due_tasks
result = _process_due_tasks()
assert result is True
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "1 sent" in calls
assert "1 failed" in calls
# =============================================
# _display_task_result
# =============================================
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.cli_error")
@patch(f"{MODULE}.logger")
class TestDisplayTaskResult:
"""Tests for _display_task_result per-status output."""
def test_status_sent(self, mock_log, _err, mock_con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t1",
"recipient": "@flow",
"task": "Check plan",
"status": "sent",
}
)
calls = " ".join(str(c) for c in mock_con.print.call_args_list)
assert "OK" in calls or "Sent" in calls or "@flow" in calls
def test_status_skipped(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t2",
"recipient": "@seedgo",
"task": "Audit",
"status": "skipped",
}
)
mock_err.assert_called()
def test_status_failed(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t3",
"recipient": "@daemon",
"task": "Heartbeat",
"status": "failed",
}
)
mock_err.assert_called()
def test_status_error(self, _log, mock_err, _con):
from aipass.daemon.apps.modules.schedule import _display_task_result
_display_task_result(
{
"id": "t4",
"recipient": "@drone",
"task": "Ping",
"status": "error",
"error": "timeout",
}
)
mock_err.assert_called()
# =============================================
# _send_email_via_drone
# =============================================
@patch(f"{MODULE}.logger")
class TestSendEmailViaDrone:
"""Tests for _send_email_via_drone subprocess wrapper."""
@patch("subprocess.run")
def test_success(self, mock_run, _log):
from aipass.daemon.apps.modules.schedule import _send_email_via_drone
mock_run.return_value = MagicMock(returncode=0)
assert _send_email_via_drone("@flow", "subj", "body") is True
mock_run.assert_called_once()
@patch("subprocess.run", side_effect=OSError("no drone"))
def test_failure(self, _run, _log):
from aipass.daemon.apps.modules.schedule import _send_email_via_drone
assert _send_email_via_drone("@flow", "subj", "body") is False
@@ -0,0 +1,351 @@
# =================== AIPass ====================
# Name: test_scheduler_bot.py
# Description: Tests for TDPLAN-0008 Phase 1 — scheduler bot daemon layer
# Version: 1.0.0
# Created: 2026-06-25
# Modified: 2026-06-25
# =============================================
"""Tests for TDPLAN-0008 Phase 1: status capture, queue view, lifecycle notifications, archive."""
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
import pytest
from aipass.daemon.apps.handlers.schedule.runstate import (
update_job_runstate,
record_job_failure,
)
from aipass.daemon.apps.handlers.schedule.telegram_notifier import (
notify_triggered,
notify_complete,
notify_error,
)
from aipass.daemon.apps.modules.queue import (
_build_queue,
_build_json_output,
_schedule_human,
handle_command,
)
# ── Fixtures ──────────────────────────────────────────
@pytest.fixture
def once_job():
"""A once-type job that fires on due_date."""
return {
"owner": "@api",
"id": "data-check",
"enabled": True,
"schedule": {"type": "once", "due_date": datetime.now().strftime("%Y-%m-%d")},
"wake": {"fresh": True, "model": "haiku"},
"prompt": "Check the live data and report back.",
}
@pytest.fixture
def interval_job():
"""An interval-type job."""
return {
"owner": "@commons",
"id": "rotation",
"enabled": True,
"schedule": {"type": "interval", "interval_minutes": 120},
"wake": {"fresh": True},
"prompt": "Rotate community content.",
}
# ── Test 1: once job fires on due_date, then marks completed ───
class TestOnceJobLifecycle:
"""Verify once jobs fire on/after due_date and mark completed."""
def test_fires_on_due_date(self, once_job):
"""Once job with today's due_date gets last_status=success + completed."""
runstate = {"jobs": {}}
update_job_runstate(runstate, "@api", "data-check", once_job["schedule"])
entry = runstate["jobs"]["@api/data-check"]
assert entry["last_status"] == "success"
assert "completed" in entry
assert entry["last_success_at"] is not None
assert entry["last_error"] is None
def test_completed_once_excluded_from_queue(self, once_job):
"""Completed once jobs are filtered out of the queue view."""
runstate = {
"jobs": {
"@api/data-check": {
"completed": "2026-06-25T10:00:00",
"last_run": "2026-06-25T10:00:00",
}
}
}
entries = _build_queue([once_job], runstate)
assert len(entries) == 0
# ── Test 2: fire emits notify_triggered then notify_complete; failed emits notify_error ──
class TestLifecycleNotifications:
"""Verify telegram notifications fire on real job events."""
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
def test_triggered_sends_running(self, mock_send):
"""notify_triggered sends running ping."""
mock_send.return_value = True
result = notify_triggered("@api", "data-check")
assert result is True
call_msg = mock_send.call_args[0][0]
assert "@api/data-check" in call_msg
assert "running" in call_msg
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
def test_complete_sends_dispatched(self, mock_send):
"""notify_complete sends dispatched ping with summary."""
mock_send.return_value = True
result = notify_complete("@api", "data-check", "Agent spawned OK")
assert result is True
call_msg = mock_send.call_args[0][0]
assert "dispatched" in call_msg
assert "Agent spawned OK" in call_msg
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
def test_error_sends_failed(self, mock_send):
"""notify_error sends failed ping with error detail."""
mock_send.return_value = True
result = notify_error("@api", "data-check", "timeout")
assert result is True
call_msg = mock_send.call_args[0][0]
assert "FAILED" in call_msg
assert "timeout" in call_msg
# ── Test 3: runstate records last_status + last_error on both paths ──
class TestRunstateStatusCapture:
"""Verify runstate tracks status on success AND failure."""
def test_success_records_status(self):
"""Successful fire sets last_status=success, last_success_at, clears error."""
runstate = {"jobs": {}}
schedule = {"type": "interval", "interval_minutes": 60}
update_job_runstate(runstate, "@commons", "test", schedule)
entry = runstate["jobs"]["@commons/test"]
assert entry["last_status"] == "success"
assert entry["last_success_at"] is not None
assert entry["last_error"] is None
def test_failure_records_status(self):
"""Failed fire sets last_status=failed, last_failure_at, last_error."""
runstate = {"jobs": {}}
record_job_failure(runstate, "@commons", "test", "branch locked")
entry = runstate["jobs"]["@commons/test"]
assert entry["last_status"] == "failed"
assert entry["last_failure_at"] is not None
assert entry["last_error"] == "branch locked"
def test_failure_preserves_last_run(self):
"""Failure path still records last_run timestamp."""
runstate = {"jobs": {}}
record_job_failure(runstate, "@x", "y", "err")
entry = runstate["jobs"]["@x/y"]
assert "last_run" in entry
def test_error_truncated(self):
"""Long error messages are truncated to 500 chars."""
runstate = {"jobs": {}}
record_job_failure(runstate, "@x", "y", "x" * 1000)
entry = runstate["jobs"]["@x/y"]
assert len(entry["last_error"]) == 500
# ── Test 4: queue --json returns frozen schema ──
class TestQueueJsonSchema:
"""Verify queue --json output matches the frozen contract."""
def test_schema_structure(self, interval_job):
"""JSON output has generated_at, count, jobs array."""
runstate = {"jobs": {}}
entries = _build_queue([interval_job], runstate)
output = _build_json_output(entries)
assert "generated_at" in output
assert "count" in output
assert isinstance(output["jobs"], list)
assert output["count"] == len(output["jobs"])
def test_job_fields(self, interval_job):
"""Each job in output has all frozen-schema fields."""
runstate = {"jobs": {}}
entries = _build_queue([interval_job], runstate)
job_out = entries[0]
required_fields = [
"owner",
"id",
"enabled",
"type",
"schedule_human",
"next_run",
"last_run",
"last_status",
"last_error",
"prompt_preview",
"wake",
]
for field in required_fields:
assert field in job_out, f"Missing field: {field}"
def test_type_values(self, once_job, interval_job):
"""Type field matches schedule type."""
runstate = {"jobs": {}}
once_entries = _build_queue([once_job], runstate)
assert once_entries[0]["type"] == "once"
interval_entries = _build_queue([interval_job], runstate)
assert interval_entries[0]["type"] == "interval"
def test_schedule_human_formats(self):
"""schedule_human renders each type correctly."""
assert _schedule_human({"schedule": {"type": "once", "due_date": "2026-07-02"}}) == "2026-07-02"
assert _schedule_human({"schedule": {"type": "daily", "time": "04:00"}}) == "daily @ 04:00"
assert _schedule_human({"schedule": {"type": "hourly", "time": "30"}}) == "hourly @ :30"
assert _schedule_human({"schedule": {"type": "interval", "interval_minutes": 120}}) == "every 2h"
assert _schedule_human({"schedule": {"type": "interval", "interval_minutes": 30}}) == "every 30m"
# ── Test 5: empty tick emits ZERO telegram calls ──
class TestEmptyTickNoNotify:
"""Verify no telegram calls on empty ticks (no due jobs)."""
@patch("aipass.daemon.apps.modules.run.discover_jobs", return_value=[])
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
def test_no_jobs_no_send(self, mock_send, mock_discover):
"""Empty tick with no discovered jobs makes zero telegram calls."""
from aipass.daemon.apps.modules.run import run_tick
run_tick(dry_run=True)
mock_send.assert_not_called()
@patch("aipass.daemon.apps.modules.run.discover_jobs")
@patch("aipass.daemon.apps.modules.run.load_runstate")
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
def test_no_due_jobs_no_send(self, mock_send, mock_rs, mock_discover):
"""Tick with jobs but none due makes zero telegram calls."""
from aipass.daemon.apps.modules.run import run_tick
mock_discover.return_value = [
{
"owner": "@commons",
"id": "test",
"enabled": True,
"schedule": {"type": "interval", "interval_minutes": 9999},
"wake": {},
"prompt": "test",
}
]
mock_rs.return_value = {"jobs": {"@commons/test": {"last_run": datetime.now().isoformat()}}}
run_tick()
mock_send.assert_not_called()
# ── Test 6: send is fail-soft ──
class TestFailSoft:
"""Verify telegram send failures don't block job firing."""
@patch("aipass.daemon.apps.handlers.schedule.telegram_notifier._send")
def test_send_returns_false_on_failure(self, mock_send):
"""When _send fails, notify functions return False gracefully."""
mock_send.return_value = False
assert notify_triggered("@x", "y") is False
assert notify_complete("@x", "y", "s") is False
assert notify_error("@x", "y", "e") is False
@patch(
"aipass.skills.lib.telegram.apps.handlers.notifier.send_telegram_notification",
side_effect=Exception("connection refused"),
)
def test_exception_caught(self, mock_notifier):
"""Exception in send_telegram_notification is caught, returns False."""
from aipass.daemon.apps.handlers.schedule.telegram_notifier import _send
result = _send("test message")
assert result is False
@patch("aipass.daemon.apps.modules.run.save_runstate")
@patch("aipass.daemon.apps.modules.run.record_job_failure")
@patch("aipass.daemon.apps.modules.run.update_job_runstate")
@patch("aipass.daemon.apps.modules.run.discover_jobs")
@patch("aipass.daemon.apps.modules.run.load_runstate", return_value={"jobs": {}})
def test_fire_continues_when_notify_fails(self, mock_rs, mock_discover, mock_update, mock_fail, mock_save):
"""Job fires and records status even when telegram is down."""
from aipass.daemon.apps.modules.run import run_tick
mock_discover.return_value = [
{
"owner": "@commons",
"id": "test",
"enabled": True,
"schedule": {"type": "interval", "interval_minutes": 1},
"wake": {"fresh": True},
"prompt": "test",
"notify": True,
}
]
with patch("aipass.daemon.apps.modules.run._fire_job", return_value=(True, "")):
results = run_tick()
assert results["fired"] == 1
# ── Test 7: dormant registries archived ──
class TestDormantArchived:
"""Verify dormant registries are archived and no live code references them."""
def test_original_data_files_gone(self):
"""Original data files no longer at daemon_json/ root."""
daemon_root = Path(__file__).resolve().parents[1]
assert not (daemon_root / "daemon_json" / "schedule.json").exists()
assert not (daemon_root / "daemon_json" / "actions_registry.json").exists()
def test_task_registry_not_importable(self):
"""task_registry handler is gone from the live import path."""
with pytest.raises(ImportError):
from aipass.daemon.apps.handlers.schedule.task_registry import load_tasks # type: ignore[import-not-found] # noqa: F401
def test_actions_registry_not_importable(self):
"""actions_registry handler is gone from the live import path."""
with pytest.raises(ImportError):
from aipass.daemon.apps.handlers.actions.actions_registry import load_registry # type: ignore[import-not-found] # noqa: F401
def test_schedule_module_retired(self):
"""Schedule module handle_command shows retirement notice, not old CRUD."""
from aipass.daemon.apps.modules.schedule import handle_command as sched_cmd
assert sched_cmd("schedule", []) is True
assert sched_cmd("schedule", ["create", "test"]) is True
def test_actions_module_retired(self):
"""Actions module handle_command shows retirement notice, not old CRUD."""
from aipass.daemon.apps.modules.actions import handle_command as act_cmd
assert act_cmd("actions", []) is True
assert act_cmd("actions", ["list"]) is True
def test_queue_command_wired(self):
"""drone @daemon queue is routable."""
assert handle_command("queue", []) is True
assert handle_command("notqueue", []) is False
@@ -1,488 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_scheduler_cron.py - Scheduler Cron Tests
# Date: 2026-04-02
# Version: 1.0.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-04-02): Initial creation - scheduler_cron dispatch path tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Full mock isolation (no real subprocesses or locks)
# =============================================
"""Tests for scheduler_cron dispatch paths."""
import subprocess
import sys
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
MODULE = "aipass.daemon.apps.scheduler_cron"
# =============================================
# FIXTURES
# =============================================
def _make_task(
task_id: str = "abc12345-6789",
recipient: str = "@devpulse",
task: str = "Run morning briefing",
message: str = "Details here",
) -> dict:
"""Build a minimal task dict for testing."""
return {
"id": task_id,
"recipient": recipient,
"task": task,
"message": message,
}
@pytest.fixture(autouse=True)
def _silence_logging():
"""Suppress logger and console output for all tests."""
with (
patch(f"{MODULE}.logger"),
patch(f"{MODULE}.console"),
patch(f"{MODULE}.log"),
):
yield
# =============================================
# _send_email_via_drone
# =============================================
class TestSendEmailViaDrone:
"""Tests for _send_email_via_drone subprocess wrapper."""
def test_success(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
mock_result = MagicMock(returncode=0)
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
result = _send_email_via_drone("@devpulse", "Subject", "Body")
assert result is True
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert cmd[:3] == ["drone", "@ai_mail", "send"]
assert "--dispatch" in cmd
def test_no_auto_execute(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
mock_result = MagicMock(returncode=0)
with patch(f"{MODULE}.subprocess.run", return_value=mock_result) as mock_run:
_send_email_via_drone("@devpulse", "Subj", "Msg", auto_execute=False)
cmd = mock_run.call_args[0][0]
assert "--dispatch" not in cmd
def test_nonzero_returncode(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
mock_result = MagicMock(returncode=1)
with patch(f"{MODULE}.subprocess.run", return_value=mock_result):
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
assert result is False
def test_subprocess_error(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
with patch(f"{MODULE}.subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="drone", timeout=15)):
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
assert result is False
def test_os_error(self):
from aipass.daemon.apps.scheduler_cron import _send_email_via_drone
with patch(f"{MODULE}.subprocess.run", side_effect=OSError("drone not found")):
result = _send_email_via_drone("@devpulse", "Subj", "Msg")
assert result is False
# =============================================
# _next_cron_run
# =============================================
class TestNextCronRun:
"""Tests for next cron run time calculation."""
def test_before_half_hour(self):
from aipass.daemon.apps.scheduler_cron import _next_cron_run
fake_now = datetime(2026, 4, 2, 10, 15, 0)
with patch(f"{MODULE}.datetime") as mock_dt:
mock_dt.now.return_value = fake_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
result = _next_cron_run()
assert result == "10:30"
def test_after_half_hour(self):
from aipass.daemon.apps.scheduler_cron import _next_cron_run
fake_now = datetime(2026, 4, 2, 10, 45, 0)
with patch(f"{MODULE}.datetime") as mock_dt:
mock_dt.now.return_value = fake_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
result = _next_cron_run()
assert result == "11:00"
def test_before_midnight_rollover(self):
from aipass.daemon.apps.scheduler_cron import _next_cron_run
fake_now = datetime(2026, 4, 2, 23, 45, 0)
with patch(f"{MODULE}.datetime") as mock_dt:
mock_dt.now.return_value = fake_now
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
result = _next_cron_run()
assert result == "00:00"
# =============================================
# _process_single_task
# =============================================
class TestProcessSingleTask:
"""Tests for the single-task dispatch function."""
def test_success_path(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching") as mock_dispatch,
patch(f"{MODULE}.send_email_direct", return_value=True) as mock_send,
patch(f"{MODULE}.mark_completed") as mock_complete,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
_process_single_task(task, results)
mock_dispatch.assert_called_once_with(task["id"])
mock_send.assert_called_once()
mock_complete.assert_called_once_with(task["id"])
assert results["success"] == 1
assert results["failed"] == 0
def test_mark_dispatching_failure(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching", side_effect=RuntimeError("lock error")),
patch(f"{MODULE}.send_email_direct") as mock_send,
):
_process_single_task(task, results)
mock_send.assert_not_called()
assert results["failed"] == 1
assert len(results["errors"]) == 1
def test_email_unavailable(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.AI_MAIL_AVAILABLE", False),
patch(f"{MODULE}.mark_pending") as mock_pending,
):
_process_single_task(task, results)
mock_pending.assert_called_once_with(task["id"])
assert results["failed"] == 1
def test_email_send_returns_false(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", return_value=False),
patch(f"{MODULE}.mark_pending") as mock_pending,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
_process_single_task(task, results)
mock_pending.assert_called_once_with(task["id"])
assert results["failed"] == 1
assert results["success"] == 0
def test_email_exception_resets_to_pending(self):
from aipass.daemon.apps.scheduler_cron import _process_single_task
results = {"success": 0, "failed": 0, "errors": []}
task = _make_task()
with (
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", side_effect=ConnectionError("timeout")),
patch(f"{MODULE}.mark_pending") as mock_pending,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
_process_single_task(task, results)
mock_pending.assert_called_once_with(task["id"])
assert results["failed"] == 1
# =============================================
# process_due_tasks
# =============================================
class TestProcessDueTasks:
"""Tests for the top-level due-task processor."""
def test_no_tasks_due(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", return_value=[]),
):
results = process_due_tasks()
assert results["due"] == 0
assert results["success"] == 0
def test_task_registry_unavailable(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", False):
results = process_due_tasks()
assert results["due"] == 0
assert results["success"] == 0
def test_stale_dispatch_recovery(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=3) as mock_recover,
patch(f"{MODULE}.get_due_tasks", return_value=[]),
):
results = process_due_tasks()
mock_recover.assert_called_once_with(max_age_minutes=5)
assert results["recovered"] == 3
def test_stale_recovery_exception(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", side_effect=RuntimeError("fs error")),
patch(f"{MODULE}.get_due_tasks", return_value=[]),
):
results = process_due_tasks()
assert len(results["errors"]) == 1
assert "Stale recovery" in results["errors"][0]
def test_get_due_tasks_exception(self):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", side_effect=RuntimeError("corrupt JSON")),
):
results = process_due_tasks()
assert "Load tasks" in results["errors"][0]
@patch(f"{MODULE}.time.sleep")
def test_successful_send(self, _mock_sleep):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
task = _make_task()
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", return_value=True),
patch(f"{MODULE}.mark_completed"),
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
results = process_due_tasks()
assert results["due"] == 1
assert results["success"] == 1
assert results["failed"] == 0
@patch(f"{MODULE}.time.sleep")
def test_send_failure_marks_pending(self, _mock_sleep):
from aipass.daemon.apps.scheduler_cron import process_due_tasks
task = _make_task()
with (
patch(f"{MODULE}.TASK_REGISTRY_AVAILABLE", True),
patch(f"{MODULE}.recover_stale_dispatches", return_value=0),
patch(f"{MODULE}.get_due_tasks", return_value=[task]),
patch(f"{MODULE}.mark_dispatching"),
patch(f"{MODULE}.send_email_direct", return_value=False),
patch(f"{MODULE}.mark_pending") as mock_pending,
patch(f"{MODULE}.AI_MAIL_AVAILABLE", True),
):
results = process_due_tasks()
mock_pending.assert_called_once()
assert results["failed"] == 1
# =============================================
# _run_locked
# =============================================
class TestRunLocked:
"""Tests for the locked orchestration function."""
def test_success_no_errors(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
tick_results = {"discovered": 0, "enabled": 0, "due": 0, "fired": 0, "failed": 0, "skipped": 0}
with (
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
patch(f"{MODULE}.run_tick", return_value=tick_results),
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
):
code = _run_locked()
assert code == 0
def test_returns_1_on_task_failures(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
task_results = {"due": 1, "success": 0, "failed": 1, "recovered": 0, "errors": ["fail"]}
tick_results = {"discovered": 0, "enabled": 0, "due": 0, "fired": 0, "failed": 0, "skipped": 0}
with (
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
patch(f"{MODULE}.run_tick", return_value=tick_results),
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
):
code = _run_locked()
assert code == 1
def test_process_due_tasks_unhandled_exception(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
with patch(f"{MODULE}.process_due_tasks", side_effect=RuntimeError("boom")):
code = _run_locked()
assert code == 1
def test_run_tick_exception_handled(self):
from aipass.daemon.apps.scheduler_cron import _run_locked
task_results = {"due": 0, "success": 0, "failed": 0, "recovered": 0, "errors": []}
with (
patch(f"{MODULE}.process_due_tasks", return_value=task_results),
patch(f"{MODULE}.run_tick", side_effect=RuntimeError("tick boom")),
patch(f"{MODULE}._next_cron_run", return_value="10:30"),
):
code = _run_locked()
assert code == 0
# =============================================
# main
# =============================================
class TestMain:
"""Tests for the main entry point."""
def test_no_args_introspection(self):
from aipass.daemon.apps.scheduler_cron import main
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py"]),
patch(f"{MODULE}.print_introspection") as mock_intro,
):
code = main()
mock_intro.assert_called_once()
assert code == 0
def test_help_flag(self):
from aipass.daemon.apps.scheduler_cron import main
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "--help"]),
patch(f"{MODULE}.print_help") as mock_help,
):
with pytest.raises(SystemExit) as exc_info:
main()
mock_help.assert_called_once()
assert exc_info.value.code == 0
@pytest.mark.skipif(
sys.platform == "win32",
reason="patches fcntl.flock; fcntl is Unix-only (None on Windows). Scheduler skips locking on non-Unix.",
)
def test_lock_acquisition_failure(self, tmp_path):
from aipass.daemon.apps.scheduler_cron import main
lock_file = tmp_path / "schedule.lock"
mock_fd = MagicMock()
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
patch(f"{MODULE}.json_handler"),
patch(f"{MODULE}.LOCK_FILE", lock_file),
patch("builtins.open", return_value=mock_fd),
patch(f"{MODULE}.fcntl.flock", side_effect=OSError("locked")),
):
code = main()
assert code == 0 # graceful skip when another instance is running
mock_fd.close.assert_called()
@pytest.mark.skipif(
sys.platform == "win32",
reason="patches fcntl.flock; fcntl is Unix-only (None on Windows). Scheduler skips locking on non-Unix.",
)
def test_lock_acquired_runs_locked(self, tmp_path):
from aipass.daemon.apps.scheduler_cron import main
lock_file = tmp_path / "schedule.lock"
mock_fd = MagicMock()
with (
patch(f"{MODULE}.sys.argv", ["scheduler_cron.py", "run"]),
patch(f"{MODULE}.json_handler"),
patch(f"{MODULE}.LOCK_FILE", lock_file),
patch("builtins.open", return_value=mock_fd),
patch(f"{MODULE}.fcntl.flock"),
patch(f"{MODULE}._run_locked", return_value=0) as mock_run,
):
code = main()
mock_run.assert_called_once()
assert code == 0
@@ -1,117 +0,0 @@
# =================== AIPass ====================
# Name: test_scheduler_ops.py
# Description: Tests for the scheduler_ops facade module
# Version: 1.0.0
# Created: 2026-04-03
# Modified: 2026-04-03
# =============================================
"""Tests for the scheduler_ops facade module (apps/modules/scheduler_ops.py)."""
from unittest.mock import patch
MODULE = "aipass.daemon.apps.modules.scheduler_ops"
# =============================================
# handle_command — routing
# =============================================
@patch(f"{MODULE}.json_handler")
@patch(f"{MODULE}.console")
@patch(f"{MODULE}.logger")
class TestHandleCommand:
"""Tests for handle_command routing."""
def test_wrong_command_returns_false(self, _log, _con, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("not-scheduler-ops", []) is False
def test_no_args_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
result = handle_command("scheduler-ops", [])
assert result is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_help_flag_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["--help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_h_flag_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["-h"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_help_word_shows_introspection(self, _log, mock_console, _jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["help"]) is True
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
def test_status_arg_shows_registry_info(self, _log, mock_console, mock_jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
assert handle_command("scheduler-ops", ["status"]) is True
mock_jh.log_operation.assert_called_once_with("scheduler_ops_status")
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Scheduler Ops" in c for c in calls)
def test_status_prints_task_registry_availability(self, _log, mock_console, mock_jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
handle_command("scheduler-ops", ["status"])
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Task registry" in c for c in calls)
def test_status_prints_action_registry_availability(self, _log, mock_console, mock_jh):
from aipass.daemon.apps.modules.scheduler_ops import handle_command
handle_command("scheduler-ops", ["status"])
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("Action registry" in c for c in calls)
# =============================================
# Module-level availability flags
# =============================================
class TestRegistryAvailability:
"""Verify that registry imports succeed in the test environment."""
def test_task_registry_available(self):
from aipass.daemon.apps.modules.scheduler_ops import TASK_REGISTRY_AVAILABLE
assert TASK_REGISTRY_AVAILABLE is True
def test_action_registry_available(self):
from aipass.daemon.apps.modules.scheduler_ops import ACTION_REGISTRY_AVAILABLE
assert ACTION_REGISTRY_AVAILABLE is True
# =============================================
# print_introspection
# =============================================
@patch(f"{MODULE}.console")
class TestPrintIntrospection:
"""Tests for print_introspection output."""
def test_prints_module_header(self, mock_console):
from aipass.daemon.apps.modules.scheduler_ops import print_introspection
print_introspection()
calls = [str(c) for c in mock_console.print.call_args_list]
assert any("scheduler_ops Module" in c for c in calls)
@@ -1,588 +0,0 @@
# ===================AIPASS====================
# META DATA HEADER
# Name: test_task_registry.py - Task Registry Tests
# Date: 2026-03-24
# Version: 1.0.0
# Category: daemon/tests
#
# CHANGELOG (Max 5 entries):
# - v1.0.0 (2026-03-24): Initial creation - task_registry handler tests
#
# CODE STANDARDS:
# - Pytest conventions
# - Temp dir isolation (no writes to real registry)
# =============================================
"""Tests for the scheduled task registry handler."""
import json
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
from aipass.daemon.apps.handlers.schedule import task_registry as _mod
parse_due_date = _mod.parse_due_date
create_task = _mod.create_task
load_tasks = _mod.load_tasks
save_tasks = _mod.save_tasks
get_due_tasks = _mod.get_due_tasks
mark_dispatching = _mod.mark_dispatching
mark_completed = _mod.mark_completed
mark_pending = _mod.mark_pending
recover_stale_dispatches = _mod.recover_stale_dispatches
delete_task = _mod.delete_task
get_task_by_id = _mod.get_task_by_id
get_pending_tasks = _mod.get_pending_tasks
ensure_lock_dir = _mod.ensure_lock_dir
@pytest.fixture(autouse=True)
def isolate_registry(tmp_path):
"""Redirect SCHEDULE_JSON_PATH to a temp dir for every test."""
test_file = tmp_path / "schedule.json"
original = _mod.SCHEDULE_JSON_PATH
_mod.SCHEDULE_JSON_PATH = test_file
yield test_file
_mod.SCHEDULE_JSON_PATH = original
# =============================================
# DATE PARSING TESTS
# =============================================
class TestParseDueDate:
def test_days_format(self):
"""'7d' should resolve to 7 days from today."""
result = parse_due_date("7d")
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
assert result == expected
def test_days_format_single_digit(self):
"""'1d' should resolve to tomorrow."""
result = parse_due_date("1d")
expected = (datetime.now().date() + timedelta(days=1)).isoformat()
assert result == expected
def test_weeks_format(self):
"""'1w' should resolve to 1 week from today."""
result = parse_due_date("1w")
expected = (datetime.now().date() + timedelta(weeks=1)).isoformat()
assert result == expected
def test_weeks_format_multiple(self):
"""'2w' should resolve to 2 weeks from today."""
result = parse_due_date("2w")
expected = (datetime.now().date() + timedelta(weeks=2)).isoformat()
assert result == expected
def test_iso_date_format(self):
"""'2026-06-15' should pass through as-is."""
result = parse_due_date("2026-06-15")
assert result == "2026-06-15"
def test_whitespace_stripped(self):
"""Leading/trailing whitespace should be stripped."""
result = parse_due_date(" 7d ")
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
assert result == expected
def test_case_insensitive_days(self):
"""'7D' should work the same as '7d'."""
result = parse_due_date("7D")
expected = (datetime.now().date() + timedelta(days=7)).isoformat()
assert result == expected
def test_case_insensitive_weeks(self):
"""'2W' should work the same as '2w'."""
result = parse_due_date("2W")
expected = (datetime.now().date() + timedelta(weeks=2)).isoformat()
assert result == expected
def test_invalid_format_raises(self):
"""Unsupported format should raise ValueError."""
with pytest.raises(ValueError, match="Invalid date format"):
parse_due_date("next tuesday")
def test_invalid_iso_date_raises(self):
"""Invalid calendar date in ISO format should raise ValueError."""
with pytest.raises(ValueError, match="Invalid date"):
parse_due_date("2026-02-30")
def test_empty_string_raises(self):
"""Empty string should raise ValueError."""
with pytest.raises(ValueError, match="Invalid date format"):
parse_due_date("")
def test_zero_days(self):
"""'0d' should resolve to today."""
result = parse_due_date("0d")
expected = datetime.now().date().isoformat()
assert result == expected
# =============================================
# LOAD / SAVE TESTS
# =============================================
class TestLoadSave:
def test_load_creates_file_if_missing(self, isolate_registry):
"""load_tasks should create schedule.json if it does not exist."""
assert not isolate_registry.exists()
tasks = load_tasks()
assert tasks == []
assert isolate_registry.exists()
def test_load_returns_empty_on_fresh_file(self):
"""Fresh schedule.json should have no tasks."""
tasks = load_tasks()
assert tasks == []
def test_save_and_load_roundtrip(self, isolate_registry):
"""save_tasks then load_tasks should return the same data."""
sample = [{"id": "abc123", "task": "test", "status": "pending"}]
assert save_tasks(sample) is True
loaded = load_tasks()
assert len(loaded) == 1
assert loaded[0]["id"] == "abc123"
def test_save_overwrites_existing(self, isolate_registry):
"""Saving new tasks should fully replace existing data."""
save_tasks([{"id": "first", "status": "pending"}])
save_tasks([{"id": "second", "status": "pending"}])
loaded = load_tasks()
assert len(loaded) == 1
assert loaded[0]["id"] == "second"
def test_load_handles_corrupt_json(self, isolate_registry):
"""Corrupt JSON should return empty list, not crash."""
isolate_registry.parent.mkdir(parents=True, exist_ok=True)
isolate_registry.write_text("{invalid json", encoding="utf-8")
tasks = load_tasks()
assert tasks == []
# =============================================
# CREATE TASK TESTS
# =============================================
class TestCreateTask:
@patch.object(_mod.json_handler, "log_operation")
def test_create_basic(self, mock_log):
"""Create a task and verify all fields."""
task = create_task(
task="Check backup health",
due_date="7d",
recipient="@devpulse",
message="Verify backup systems",
)
assert task["task"] == "Check backup health"
assert task["recipient"] == "@devpulse"
assert task["message"] == "Verify backup systems"
assert task["status"] == "pending"
assert len(task["id"]) == 16
assert task["id"].isalnum()
assert task["created"] == datetime.now().date().isoformat()
mock_log.assert_called_once_with("task_created")
@patch.object(_mod.json_handler, "log_operation")
def test_create_persists_to_json(self, mock_log, isolate_registry):
"""Created task should be saved to the JSON file."""
create_task(
task="persisted task",
due_date="1d",
recipient="@seedgo",
message="msg",
)
raw = json.loads(isolate_registry.read_text(encoding="utf-8"))
assert len(raw["tasks"]) == 1
assert raw["tasks"][0]["task"] == "persisted task"
@patch.object(_mod.json_handler, "log_operation")
def test_create_multiple_tasks(self, mock_log):
"""Multiple tasks should accumulate in the registry."""
create_task(task="t1", due_date="1d", recipient="@a", message="m1")
create_task(task="t2", due_date="2d", recipient="@b", message="m2")
tasks = load_tasks()
assert len(tasks) == 2
assert tasks[0]["task"] == "t1"
assert tasks[1]["task"] == "t2"
def test_create_invalid_date_raises(self):
"""create_task should propagate ValueError from bad due_date."""
with pytest.raises(ValueError):
create_task(task="bad", due_date="xyz", recipient="@a", message="m")
# =============================================
# DUE TASKS TESTS
# =============================================
class TestDueTasks:
def test_overdue_task_returned(self, isolate_registry):
"""A pending task with a past due_date should be returned."""
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
save_tasks(
[
{
"id": "past01",
"due_date": yesterday,
"status": "pending",
"task": "overdue",
}
]
)
due = get_due_tasks()
assert len(due) == 1
assert due[0]["id"] == "past01"
def test_today_task_returned(self, isolate_registry):
"""A pending task due today should be returned."""
today = datetime.now().date().isoformat()
save_tasks(
[
{
"id": "today01",
"due_date": today,
"status": "pending",
"task": "due today",
}
]
)
due = get_due_tasks()
assert len(due) == 1
assert due[0]["id"] == "today01"
def test_future_task_not_returned(self, isolate_registry):
"""A pending task with a future due_date should not be returned."""
future = (datetime.now().date() + timedelta(days=30)).isoformat()
save_tasks(
[
{
"id": "future01",
"due_date": future,
"status": "pending",
"task": "future task",
}
]
)
due = get_due_tasks()
assert len(due) == 0
def test_dispatching_task_excluded(self, isolate_registry):
"""Tasks with status 'dispatching' should not be returned."""
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
save_tasks(
[
{
"id": "disp01",
"due_date": yesterday,
"status": "dispatching",
"task": "already dispatching",
}
]
)
due = get_due_tasks()
assert len(due) == 0
def test_completed_task_excluded(self, isolate_registry):
"""Tasks with status 'completed' should not be returned."""
yesterday = (datetime.now().date() - timedelta(days=1)).isoformat()
save_tasks(
[
{
"id": "done01",
"due_date": yesterday,
"status": "completed",
"task": "done",
}
]
)
due = get_due_tasks()
assert len(due) == 0
def test_empty_registry_returns_empty(self):
"""Empty registry should return empty list."""
due = get_due_tasks()
assert due == []
# =============================================
# STATUS TRANSITION TESTS
# =============================================
class TestStatusTransitions:
def _seed_task(self, task_id: str = "abc12345abcd1234", status: str = "pending"):
"""Helper to seed a single task."""
save_tasks(
[
{
"id": task_id,
"task": "test",
"status": status,
"due_date": "2026-01-01",
}
]
)
return task_id
def test_mark_dispatching_success(self):
"""mark_dispatching should set status and dispatch_started."""
tid = self._seed_task()
assert mark_dispatching(tid) is True
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "dispatching"
assert "dispatch_started" in task
def test_mark_dispatching_missing(self):
"""mark_dispatching returns False for nonexistent ID."""
assert mark_dispatching("nonexistent_id__") is False
def test_mark_completed_success(self):
"""mark_completed should set status and completed_date."""
tid = self._seed_task()
assert mark_completed(tid) is True
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "completed"
assert task["completed_date"] == datetime.now().date().isoformat()
def test_mark_completed_missing(self):
"""mark_completed returns False for nonexistent ID."""
assert mark_completed("nonexistent_id__") is False
def test_mark_pending_success(self):
"""mark_pending should reset status and remove dispatch_started."""
tid = self._seed_task(status="dispatching")
# Add dispatch_started to simulate real scenario
tasks = load_tasks()
tasks[0]["dispatch_started"] = datetime.now().isoformat()
save_tasks(tasks)
assert mark_pending(tid) is True
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "pending"
assert "dispatch_started" not in task
def test_mark_pending_missing(self):
"""mark_pending returns False for nonexistent ID."""
assert mark_pending("nonexistent_id__") is False
def test_full_lifecycle(self):
"""pending -> dispatching -> completed lifecycle."""
tid = self._seed_task()
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "pending"
mark_dispatching(tid)
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "dispatching"
mark_completed(tid)
task = get_task_by_id(tid)
assert task is not None
assert task["status"] == "completed"
# =============================================
# RECOVER STALE DISPATCHES TESTS
# =============================================
class TestRecoverStale:
def test_recovers_stale_task(self, isolate_registry):
"""Task stuck in dispatching beyond max_age should be reset."""
stale_time = (datetime.now() - timedelta(minutes=10)).isoformat()
save_tasks(
[
{
"id": "stale01",
"task": "stale dispatch",
"status": "dispatching",
"dispatch_started": stale_time,
"due_date": "2026-01-01",
}
]
)
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 1
task = get_task_by_id("stale01")
assert task is not None
assert task["status"] == "pending"
assert "dispatch_started" not in task
def test_does_not_recover_recent_dispatch(self, isolate_registry):
"""Task dispatching within max_age should not be recovered."""
recent_time = (datetime.now() - timedelta(minutes=1)).isoformat()
save_tasks(
[
{
"id": "recent01",
"task": "recent dispatch",
"status": "dispatching",
"dispatch_started": recent_time,
"due_date": "2026-01-01",
}
]
)
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 0
task = get_task_by_id("recent01")
assert task is not None
assert task["status"] == "dispatching"
def test_recovers_invalid_timestamp(self, isolate_registry):
"""Task with unparseable dispatch_started should be recovered."""
save_tasks(
[
{
"id": "bad_ts01",
"task": "bad timestamp",
"status": "dispatching",
"dispatch_started": "not-a-date",
"due_date": "2026-01-01",
}
]
)
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 1
task = get_task_by_id("bad_ts01")
assert task is not None
assert task["status"] == "pending"
def test_pending_tasks_untouched(self, isolate_registry):
"""Pending tasks should not be affected by recovery."""
save_tasks(
[
{
"id": "ok01",
"task": "normal pending",
"status": "pending",
"due_date": "2026-01-01",
}
]
)
recovered = recover_stale_dispatches(max_age_minutes=5)
assert recovered == 0
task = get_task_by_id("ok01")
assert task is not None
assert task["status"] == "pending"
def test_empty_registry_returns_zero(self):
"""Recovery on empty registry should return 0."""
assert recover_stale_dispatches() == 0
# =============================================
# DELETE TASK TESTS
# =============================================
class TestDeleteTask:
def test_delete_existing(self, isolate_registry):
"""Deleting an existing task returns True and removes it."""
save_tasks([{"id": "del01", "task": "to delete", "status": "pending"}])
assert delete_task("del01") is True
assert get_task_by_id("del01") is None
assert load_tasks() == []
def test_delete_missing(self):
"""Deleting a nonexistent task returns False."""
assert delete_task("nonexistent_id__") is False
def test_delete_preserves_other_tasks(self, isolate_registry):
"""Deleting one task should leave others intact."""
save_tasks(
[
{"id": "keep01", "task": "keep this", "status": "pending"},
{"id": "del02", "task": "delete this", "status": "pending"},
]
)
delete_task("del02")
remaining = load_tasks()
assert len(remaining) == 1
assert remaining[0]["id"] == "keep01"
def test_delete_from_empty_registry(self):
"""Delete on empty registry should return False without error."""
assert delete_task("anything") is False
# =============================================
# GET PENDING TASKS TESTS
# =============================================
class TestGetPendingTasks:
"""Tests for get_pending_tasks()."""
def test_returns_only_pending(self, isolate_registry):
"""Only tasks with status 'pending' are returned."""
save_tasks(
[
{"id": "pend01", "task": "pending one", "status": "pending"},
{"id": "pend02", "task": "pending two", "status": "pending"},
{"id": "done01", "task": "done", "status": "completed"},
]
)
result = get_pending_tasks()
assert len(result) == 2
assert all(t["status"] == "pending" for t in result)
def test_excludes_dispatching_and_completed(self, isolate_registry):
"""Tasks with dispatching or completed status are excluded."""
save_tasks(
[
{"id": "disp01", "task": "dispatching", "status": "dispatching"},
{"id": "done01", "task": "completed", "status": "completed"},
{"id": "pend01", "task": "pending", "status": "pending"},
]
)
result = get_pending_tasks()
assert len(result) == 1
assert result[0]["id"] == "pend01"
def test_empty_registry_returns_empty(self):
"""Empty registry returns empty list."""
result = get_pending_tasks()
assert result == []
# =============================================
# ENSURE LOCK DIR TESTS
# =============================================
class TestEnsureLockDir:
"""Tests for ensure_lock_dir()."""
def test_creates_directory_if_missing(self, isolate_registry):
"""Creates the lock directory when it does not exist."""
lock_dir = isolate_registry.parent
if lock_dir.exists():
import shutil
shutil.rmtree(lock_dir)
assert not lock_dir.exists()
result = ensure_lock_dir()
assert lock_dir.exists()
assert lock_dir.is_dir()
assert result["path"] == str(lock_dir)
def test_returns_dict_with_path_key(self, isolate_registry):
"""Return value is a dict containing the 'path' key."""
result = ensure_lock_dir()
assert isinstance(result, dict)
assert "path" in result
assert isinstance(result["path"], str)
@@ -0,0 +1,150 @@
# =================== AIPass ====================
# Name: test_timer_install.py
# Description: Tests for the timer_install module (systemd user timer installer)
# Version: 1.0.0
# Created: 2026-06-25
# Modified: 2026-06-25
# =============================================
"""Tests for the timer_install module (systemd user timer installer)."""
import subprocess
from unittest.mock import patch, MagicMock
from pathlib import Path
from aipass.daemon.apps.modules.timer_install import (
handle_command,
HANDLED_COMMANDS,
_run_systemctl,
_install,
_uninstall,
)
class TestHandleCommand:
"""Tests for command routing."""
def test_handles_install_timer(self):
"""Verify install-timer is in handled commands."""
assert "install-timer" in HANDLED_COMMANDS
def test_handles_uninstall_timer(self):
"""Verify uninstall-timer is in handled commands."""
assert "uninstall-timer" in HANDLED_COMMANDS
def test_rejects_unknown(self):
"""Unknown commands return False."""
assert handle_command("unknown", []) is False
def test_help_flag(self, capsys):
"""Help flag prints usage and returns True."""
result = handle_command("install-timer", ["--help"])
assert result is True
class TestRunSystemctl:
"""Tests for the systemctl wrapper."""
@patch("subprocess.run")
def test_success(self, mock_run):
"""Successful systemctl returns True."""
mock_run.return_value = MagicMock(returncode=0)
assert _run_systemctl("status", "daemon-tick.timer") is True
@patch("subprocess.run")
def test_failure_returncode(self, mock_run):
"""Non-zero returncode returns False."""
mock_run.return_value = MagicMock(returncode=1, stderr="unit not found")
assert _run_systemctl("start", "daemon-tick.timer") is False
@patch("subprocess.run", side_effect=FileNotFoundError)
def test_systemctl_not_found(self, mock_run):
"""Missing systemctl returns False."""
assert _run_systemctl("status", "daemon-tick.timer") is False
@patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="systemctl", timeout=15),
)
def test_timeout(self, mock_run):
"""Timed-out systemctl returns False."""
assert _run_systemctl("status", "daemon-tick.timer") is False
class TestInstall:
"""Tests for the install flow."""
def test_install_missing_unit_file(self):
"""Returns 1 when unit files are missing."""
with patch(
"aipass.daemon.apps.modules.timer_install._DAEMON_ROOT",
Path("/nonexistent"),
):
result = _install()
assert result == 1
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
@patch("shutil.copy2")
def test_install_success(self, mock_copy, mock_systemctl, tmp_path):
"""Successful install copies files and calls systemctl 3 times."""
service = tmp_path / "daemon-tick.service"
timer = tmp_path / "daemon-tick.timer"
service.write_text("[Unit]\n")
timer.write_text("[Unit]\n")
install_dir = tmp_path / "systemd"
install_dir.mkdir()
with (
patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path),
patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir),
):
result = _install()
assert result == 0
assert mock_systemctl.call_count == 3
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl")
@patch("shutil.copy2")
def test_install_systemctl_fails(self, mock_copy, mock_systemctl, tmp_path):
"""Returns 1 when systemctl fails."""
service = tmp_path / "daemon-tick.service"
timer = tmp_path / "daemon-tick.timer"
service.write_text("[Unit]\n")
timer.write_text("[Unit]\n")
install_dir = tmp_path / "systemd"
install_dir.mkdir()
mock_systemctl.return_value = False
with (
patch("aipass.daemon.apps.modules.timer_install._DAEMON_ROOT", tmp_path),
patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", install_dir),
):
result = _install()
assert result == 1
class TestUninstall:
"""Tests for the uninstall flow."""
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
def test_uninstall_files_not_present(self, mock_systemctl, tmp_path):
"""Returns 0 even when unit files are already absent."""
with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path):
result = _uninstall()
assert result == 0
@patch("aipass.daemon.apps.modules.timer_install._run_systemctl", return_value=True)
def test_uninstall_removes_files(self, mock_systemctl, tmp_path):
"""Removes unit files from the target directory."""
service = tmp_path / "daemon-tick.service"
timer = tmp_path / "daemon-tick.timer"
service.write_text("[Unit]\n")
timer.write_text("[Unit]\n")
with patch("aipass.daemon.apps.modules.timer_install._UNIT_DIR", tmp_path):
result = _uninstall()
assert result == 0
assert not service.exists()
assert not timer.exists()
@@ -6,6 +6,12 @@ Breadcrumbs only — details in README, `--help`, `.trinity/`, `DASHBOARD.local.
DEVPULSE — the user's primary collaborator, orchestration hub. Design, plan, debug, dispatch, track. Build own modules (watchdog, feedback), DPLANs, FPLANs, memories. Venture into other branches to investigate, debug, fix small bugs. Delegate heavy multi-file builds to sub-agents. CWD is identity grounding.
# Memory entry limits — hook-enforced, over-limit = whole edit REJECTED
- The caps are NOT listed here (they'd go stale). Single source: @memory's `memory.config.json → entry_limits`, auto-rendered into each file's `*_meta` line (e.g. `todos_meta: … task ≤150 chars`). **Read the `*_meta` line of the section you're writing — the live cap is right there.**
- **Draft to ~80% of the cap** — never write at the ceiling. Unsure? `echo -n 'text' | wc -c` first.
- If rejected anyway: **rewrite hard in ONE pass** (cut to ~80%), never shave a few chars per retry.
# How you work
- Build own directly: modules, DPLANs, FPLANs, memories — edit freely.
+12 -9
View File
@@ -8,11 +8,14 @@
{
"file": "apps/drone.py",
"standard": "cli",
"lines": [
166,
168
],
"reason": "Raw passthrough of module subprocess stdout/stderr — console.print() breaks routed output"
"lines": [244, 246, 360, 362, 479, 481, 585, 587],
"reason": "Raw passthrough of routed command stdout/stderr — console.print() wraps at 80 cols when piped, corrupting JSON and machine output"
},
{
"file": "apps/modules/router.py",
"standard": "cli",
"lines": [62],
"reason": "Raw passthrough of routed command stdout — console.print() wraps at 80 cols when piped, corrupting JSON and machine output"
},
{
"file": "apps/handlers/scanning/formatters.py",
@@ -252,25 +255,25 @@
{
"file": "apps/handlers/command_registry/ops.py",
"standard": "unused_function",
"lines": [232, 263],
"functions": ["update_command", "command_exists"],
"reason": "Public CRUD API surface (update_command, command_exists) — tested, available for programmatic use. No CLI subcommand wired yet."
},
{
"file": "apps/handlers/git/pr_handler.py",
"standard": "unused_function",
"lines": [108],
"functions": ["create_pr"],
"reason": "create_pr() is deprecated per FPLAN-0210 — pr command blocked at auth tier. Handler kept for backwards compatibility; still tested in test_git_module.py."
},
{
"file": "apps/modules/git_module.py",
"standard": "unused_function",
"lines": [665],
"functions": ["get_introspective"],
"reason": "get_introspective() called dynamically via getattr() by module_registry_handler.py:219 for internal module introspection. Also tested in test_git_module, test_system_pr, test_devpulse_plugins, test_git_access."
},
{
"file": "apps/handlers/broker/daemon.py",
"standard": "unused_function",
"lines": [422],
"functions": ["start_background"],
"reason": "Threaded broker entrypoint, exercised by tests/test_broker.py; production uses blocking start(); intentionally not called in shipped non-test code."
}
],
+8 -8
View File
@@ -241,9 +241,9 @@ def _handle_module(name: str, args: List[str]) -> int:
return 1
if result.get("stdout"):
console.print(result["stdout"], end="", highlight=False, markup=False)
sys.stdout.write(result["stdout"])
if result.get("stderr"):
err_console.print(result["stderr"], end="", highlight=False, markup=False)
sys.stderr.write(result["stderr"])
return result.get("exit_code", 0)
@@ -357,9 +357,9 @@ def _handle_custom_command(args: list[str]) -> int:
return 1
if result.stdout:
console.print(result.stdout, end="", highlight=False)
sys.stdout.write(result.stdout)
if result.stderr:
err_console.print(result.stderr, end="", highlight=False)
sys.stderr.write(result.stderr)
return result.exit_code
@@ -476,9 +476,9 @@ def _handle_target(args: List[str]) -> int:
return 1
if result.stdout:
console.print(result.stdout, end="", highlight=False)
sys.stdout.write(result.stdout)
if result.stderr:
err_console.print(result.stderr, end="", highlight=False)
sys.stderr.write(result.stderr)
return result.exit_code
@@ -582,9 +582,9 @@ def main() -> int:
return 1
if isinstance(result, dict):
if result.get("stdout"):
console.print(result["stdout"], end="", highlight=False)
sys.stdout.write(result["stdout"])
if result.get("stderr"):
err_console.print(result["stderr"], end="", highlight=False)
sys.stderr.write(result["stderr"])
return result.get("exit_code", 0)
return 0 if result else 1
+13 -18
View File
@@ -41,13 +41,15 @@ def _find_real_caller():
return None, None
_BRANCH_ROOT = str(Path(__file__).resolve().parents[2])
def _extract_branch_name(filepath: str) -> str:
"""Extract branch name from a file path."""
parts = Path(filepath).parts
for i, part in enumerate(parts):
if part == "aipass":
if i + 1 < len(parts):
return parts[i + 1]
"""Extract branch name from a file path by walking up to .trinity/."""
path = Path(filepath)
for parent in [path, *path.parents]:
if (parent / ".trinity").is_dir():
return parent.name
return "unknown"
@@ -60,14 +62,13 @@ def _guard_branch_access():
"""
caller_file, import_line = _find_real_caller()
# DEBUG: Print what we found
import os
if os.environ.get("AIPASS_DEBUG_GUARD"):
import sys
print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr)
print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr)
sys.stderr.write(f"[GUARD DEBUG] caller_file = {caller_file}\n")
sys.stderr.write(f"[GUARD DEBUG] import_line = {import_line}\n")
if caller_file is None:
# Can't determine caller from real files
@@ -79,10 +80,7 @@ def _guard_branch_access():
return # Allow command-line Python through
return # Allow if truly can't determine
# Check if caller is from our branch
# MY_BRANCH is "aipass.drone" (dotted), but filesystem uses "/aipass/drone/"
branch_path = "/" + MY_BRANCH.replace(".", "/") + "/"
if branch_path in caller_file.replace("\\", "/"):
if Path(caller_file).is_relative_to(_BRANCH_ROOT):
return # Same branch, allowed
# External caller - block access
@@ -90,6 +88,7 @@ def _guard_branch_access():
caller_filename = Path(caller_file).name
blocked_import = import_line if import_line else "unknown"
module_api = f"{MY_BRANCH}.apps.modules"
raise ImportError(
f"\n{'=' * 60}\n"
f"ACCESS DENIED: Cross-branch handler import blocked\n"
@@ -99,11 +98,7 @@ def _guard_branch_access():
f" Blocked: {blocked_import}\n"
f"\n"
f" Handlers are internal to their branch.\n"
f" Use the module API instead:\n"
f" from {MY_BRANCH}.apps.modules.<module> import <function>\n"
f"\n"
f" Example:\n"
f" from {MY_BRANCH}.apps.modules.logger import logger\n"
f" Use the module API instead: {module_api}.<module>\n"
f"\n"
f" For full standards guide:\n"
f" drone @seedgo handlers\n"
@@ -27,6 +27,7 @@ from __future__ import annotations
import hashlib
import hmac as hmac_mod
import json
import os
import secrets
import socket
import threading
@@ -50,8 +51,6 @@ _TMP_BASES = (Path("/tmp"), Path("/var/tmp"))
def _find_project_root() -> Path | None:
"""Walk up from CWD to find *_REGISTRY.json; return its parent as project root."""
import os
cwd = Path.cwd()
for parent in [cwd, *cwd.parents]:
if list(parent.glob("*_REGISTRY.json")):
@@ -166,11 +165,24 @@ class BrokerDaemon:
if identity == "devpulse":
bases.append(self._repo_root)
return bases
branch_dir = self._repo_root / "src" / "aipass" / identity
if branch_dir.is_dir():
branch_dir = self._resolve_branch_dir(identity)
if branch_dir and branch_dir.is_dir():
bases.append(branch_dir)
return bases
def _resolve_branch_dir(self, identity: str) -> Path | None:
"""Find a branch directory by name via .trinity/ marker walk."""
if self._repo_root is None:
return None
for root, dirs, _files in os.walk(self._repo_root):
depth = len(Path(root).relative_to(self._repo_root).parts)
if depth > 3:
dirs.clear()
continue
if Path(root).name == identity and (Path(root) / ".trinity").is_dir():
return Path(root).resolve()
return None
def _handle_identify(self, req: BrokerRequest) -> tuple[BrokerResponse, str | None]:
"""Verify HMAC and bind identity to the connection."""
audit_entry: dict = {
@@ -18,6 +18,18 @@ from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
def _find_branch_for_path(filepath: str, repo_root: Path) -> tuple[str, Path] | None:
"""Find which branch a changed file belongs to by walking up to .trinity/."""
abs_path = (repo_root / filepath).resolve()
root = repo_root.resolve()
for parent in [abs_path.parent, *abs_path.parent.parents]:
if not parent.is_relative_to(root):
break
if (parent / ".trinity").is_dir():
return parent.name, parent
return None
def _run_test_gate(repo_root: Path) -> dict | None:
"""Run pytest for changed branches. Returns error dict if tests fail, None if all pass."""
status_result = subprocess.run(
@@ -26,21 +38,22 @@ def _run_test_gate(repo_root: Path) -> dict | None:
text=True,
cwd=str(repo_root),
)
changed_branches: set[str] = set()
changed_branches: dict[str, Path] = {}
for line in status_result.stdout.splitlines():
if len(line) < 4:
continue
filepath = line[3:].split(" -> ")[-1]
parts = Path(filepath).parts
if len(parts) >= 3 and parts[0] == "src" and parts[1] == "aipass":
changed_branches.add(parts[2])
result = _find_branch_for_path(filepath, repo_root)
if result:
name, branch_path = result
changed_branches[name] = branch_path
venv_python = repo_root / ".venv" / "bin" / "python"
python_bin = str(venv_python) if venv_python.exists() else "python3"
failed_branches: list[tuple[str, str]] = []
for branch_name in sorted(changed_branches):
test_dir = repo_root / "src" / "aipass" / branch_name / "tests"
test_dir = changed_branches[branch_name] / "tests"
if not test_dir.is_dir():
continue
try:
@@ -144,13 +144,16 @@ def find_registry() -> Path:
if hit is not None:
return hit
# Fallback — use package-relative path; glob there too
fallback_dir = Path(__file__).resolve().parents[4]
# Fallback — walk up from this file to the project root (marker-based)
_markers = (".git", "pyproject.toml", "setup.py", "setup.cfg")
fallback_dir = Path(__file__).resolve().parent
for parent in [fallback_dir, *fallback_dir.parents]:
if any((parent / m).exists() for m in _markers):
fallback_dir = parent
break
hit = _first_registry_in(fallback_dir)
if hit is not None:
return hit
# Ultimate fallback: return a conventional name so the caller
# gets a clear "not found" path in the error message.
return fallback_dir / "AIPASS_REGISTRY.json"
+18 -14
View File
@@ -67,16 +67,23 @@ def get_allowed_roots() -> list[Path]:
return roots
def _find_branch_root(path: Path, project_root: Path) -> Path | None:
"""Walk up from *path* looking for .trinity/; return branch dir or None."""
root = project_root.resolve()
for parent in [path, *path.parents]:
if not parent.is_relative_to(root):
break
if (parent / ".trinity").is_dir():
return parent
return None
def _detect_current_branch(project_root: Path | None) -> str | None:
"""Return the branch name the CWD lives in, or None."""
if project_root is None:
return None
cwd = Path.cwd().resolve()
aipass_src = project_root / "src" / "aipass"
if not cwd.is_relative_to(aipass_src):
return None
rel = cwd.relative_to(aipass_src)
return rel.parts[0] if rel.parts else None
branch_root = _find_branch_root(Path.cwd().resolve(), project_root)
return branch_root.name if branch_root else None
def _resolve_git_dir(path: Path) -> Path | None:
@@ -109,14 +116,11 @@ def check_carveouts(resolved: Path, project_root: Path | None) -> tuple[bool, st
return True, "Protected: path resolves inside .git worktree gitdir"
if project_root is not None:
aipass_src = project_root / "src" / "aipass"
if resolved.is_relative_to(aipass_src):
rel = resolved.relative_to(aipass_src)
if rel.parts:
target_branch = rel.parts[0]
current_branch = _detect_current_branch(project_root)
if current_branch is None or target_branch != current_branch:
return True, (f"Protected: path is inside sibling branch src/aipass/{target_branch}/")
target_branch_root = _find_branch_root(resolved, project_root)
if target_branch_root is not None:
current_branch = _detect_current_branch(project_root)
if current_branch is None or target_branch_root.name != current_branch:
return True, f"Protected: path is inside sibling branch {target_branch_root.name}/"
return False, ""
+4 -2
View File
@@ -14,6 +14,7 @@ to the handler layer.
"""
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
@@ -58,7 +59,7 @@ def handle_command(command: Optional[str] = None, args: Optional[List[str]] = No
cmd_args = args[2:] if len(args) > 2 else None
result = route_command(target, cmd, args=cmd_args)
if result.stdout:
console.print(result.stdout, highlight=False)
sys.stdout.write(result.stdout)
return result.exit_code == 0
if command == "route_all":
if not args:
@@ -132,7 +133,8 @@ def print_introspection():
console.print("[yellow]Connected Handlers:[/yellow]")
console.print(" [cyan]handlers/[/cyan]")
console.print(
" - [cyan]router_handler.py[/cyan] [dim](execute_branch_command — resolves and executes branch commands)[/dim]"
" - [cyan]router_handler.py[/cyan] "
"[dim](execute_branch_command — resolves and executes branch commands)[/dim]"
)
console.print(" - [cyan]executor.py[/cyan] [dim](CommandResult — subprocess execution result dataclass)[/dim]")
console.print()
+3 -1
View File
@@ -90,11 +90,12 @@ def _send_identified(broker: BrokerDaemon, branch: str, req: BrokerRequest) -> B
@pytest.fixture()
def repo_root(tmp_path: Path) -> Path:
"""Set up a mock repo root with branch directories."""
"""Set up a mock repo root with branch directories marked by .trinity/."""
root = tmp_path / "repo"
root.mkdir()
branch = root / "src" / "aipass" / "testbranch"
branch.mkdir(parents=True)
(branch / ".trinity").mkdir()
(branch / "deleteme.txt").write_text("delete me", encoding="utf-8")
(branch / "subdir").mkdir()
(branch / "subdir" / "nested.txt").write_text("nested", encoding="utf-8")
@@ -102,6 +103,7 @@ def repo_root(tmp_path: Path) -> Path:
(branch / ".git" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
sibling = root / "src" / "aipass" / "sibling"
sibling.mkdir(parents=True)
(sibling / ".trinity").mkdir()
(sibling / "important.txt").write_text("don't delete", encoding="utf-8")
return root
+10 -4
View File
@@ -387,8 +387,11 @@ class TestCommitChanges:
stderr="",
)
test_dir = repo_dir / "src" / "aipass" / "drone" / "tests"
test_dir.mkdir(parents=True)
drone_dir = repo_dir / "src" / "aipass" / "drone"
drone_dir.mkdir(parents=True)
(drone_dir / ".trinity").mkdir()
test_dir = drone_dir / "tests"
test_dir.mkdir()
with (
patch("shutil.which", return_value="/usr/bin/ruff"),
@@ -417,8 +420,11 @@ class TestCommitChanges:
mock_diff = MagicMock(returncode=1, stdout="", stderr="")
mock_commit = MagicMock(returncode=0, stdout="[main abc999] green commit", stderr="")
test_dir = repo_dir / "src" / "aipass" / "drone" / "tests"
test_dir.mkdir(parents=True)
drone_dir = repo_dir / "src" / "aipass" / "drone"
drone_dir.mkdir(parents=True)
(drone_dir / ".trinity").mkdir()
test_dir = drone_dir / "tests"
test_dir.mkdir()
with (
patch("shutil.which", return_value="/usr/bin/ruff"),
+2 -1
View File
@@ -36,10 +36,11 @@ def project_dir(tmp_path):
@pytest.fixture()
def project_with_branches(project_dir):
"""Project root with src/aipass/<branch> layout for sibling tests."""
"""Project root with branch dirs containing .trinity/ markers."""
for branch in ("drone", "api", "flow"):
d = project_dir / "src" / "aipass" / branch
d.mkdir(parents=True)
(d / ".trinity").mkdir()
(d / "README.md").write_text(f"# {branch}")
return project_dir
+2
View File
@@ -146,6 +146,8 @@ On `drone @flow close`:
Vector verification displays in console: "Vectorized: N chunks in chroma" or "NOT vectorized".
Closed plans are archived to `<repo-root>/.backup/processed_plans/`, a shared runtime namespace managed by `@backup` (see `src/aipass/backup/README.md`) and consumed by `@memory` for vectorization.
---
## Integration Points
+160
View File
@@ -869,6 +869,31 @@
"standard": "unused_function",
"reason": "sandbox_launch(), build_policy(), build_srt_config(), and resolve_bwrap_command() are consumed CROSS-BRANCH by ai_mail's dispatch_monitor.py (the launch seam at Phase 4). seedgo's intra-branch static analysis cannot see these callers. Verified: dispatch_monitor imports sandbox module to wire build_policy + sandbox_launch at agent launch."
},
{
"file": "apps/handlers/security/presence_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.presence_gate.handle' — not statically imported by design. Wired in UserPromptSubmit.presence_gate + Stop.presence_release."
},
{
"file": "apps/handlers/security/presence_gate.py",
"standard": "unused_function",
"reason": "handle() and handle_stop() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.presence_gate and Stop.presence_release."
},
{
"file": "apps/handlers/security/presence_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "apps/modules/presence.py",
"standard": "json_structure",
"reason": "Presence service uses stdlib json for .ai_central/PRESENCE.central.json — shared runtime namespace, not branch json_handler storage."
},
{
"file": "apps/modules/presence.py",
"standard": "unused_function",
"reason": "claim(), release(), refresh(), read_all() consumed by presence_gate.py handler (dynamically dispatched via engine). Static analysis cannot trace hooks.json → engine → handler → presence module."
},
{
"file": "apps/handlers/notification/telegram_response.py",
"standard": "unused_function",
@@ -898,6 +923,141 @@
"file": "tests/test_telegram_response.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers."
},
{
"file": "apps/modules/presence.py",
"standard": "json_structure",
"reason": "Uses stdlib json for .ai_central/PRESENCE.central.json shared runtime namespace — not branch data storage needing json_handler."
},
{
"file": "apps/modules/presence.py",
"standard": "modules",
"reason": "Direct file ops on .ai_central/PRESENCE.central.json — a shared runtime namespace (gitignored, cross-branch). Not a handler-level concern; the module IS the presence service that owns this file."
},
{
"file": "apps/modules/presence.py",
"standard": "dead_code",
"reason": "claim(), release(), refresh(), read_all() consumed by presence_gate.py handler (dynamically dispatched via engine). handle_command() and print_introspection() called by drone routing. Static analysis cannot trace hooks.json → engine → handler → presence module."
},
{
"file": "apps/modules/presence.py",
"standard": "unused_function",
"reason": "claim(), release(), refresh(), read_all() consumed by presence_gate.py handler (dynamically dispatched via engine). handle_command() and print_introspection() called by drone routing."
},
{
"file": "apps/handlers/security/presence_gate.py",
"standard": "dead_code",
"reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.presence_gate.handle' — not statically imported by design. Wired in UserPromptSubmit.presence_gate + Stop.presence_release."
},
{
"file": "apps/handlers/security/presence_gate.py",
"standard": "unused_function",
"reason": "handle() and handle_stop() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.presence_gate + Stop.presence_release."
},
{
"file": "apps/handlers/security/presence_gate.py",
"standard": "json_structure",
"reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."
},
{
"file": "tests/test_presence.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure."
},
{
"file": "tests/test_presence.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention."
},
{
"file": "tests/test_presence.py",
"standard": "encapsulation",
"reason": "Tests import modules directly to test implementation details."
},
{
"file": "tests/test_presence.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers."
},
{
"file": "tests/test_presence_gate.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure."
},
{
"file": "tests/test_presence_gate.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention."
},
{
"file": "tests/test_presence_gate.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details."
},
{
"file": "tests/test_presence_gate.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers."
},
{
"file": "apps/modules/cc_sessions.py",
"standard": "json_structure",
"reason": "Reads CC-native ~/.claude/sessions/*.json (external files, not branch data) with stdlib json — no JSON file ops needing json_handler."
},
{
"file": "apps/modules/cc_sessions.py",
"standard": "modules",
"reason": "Direct file ops on ~/.claude/sessions/*.json — external CC-native session files, not branch data. The module IS the CC session reader service; json_handler is for branch-managed JSON, not external platform files."
},
{
"file": "apps/handlers/lifecycle/session_boot.py",
"standard": "json_structure",
"reason": "Boot wrapper has no JSON file ops — delegates to cc_sessions module for session reads. No json_handler needed."
},
{
"file": "apps/handlers/lifecycle/session_boot.py",
"standard": "dead_code",
"reason": "Boot wrapper invoked via __main__ entry point from shell shim (tools/install_boot_shim.sh) — not engine-dispatched. boot() and main() called at process entry, not statically imported."
},
{
"file": "apps/handlers/lifecycle/session_boot.py",
"standard": "unused_function",
"reason": "boot() and main() invoked via __main__ entry point from shell shim — not statically imported. _resolve_claude_binary(), _find_live_sessions(), _find_tmux(), etc. are internal helpers called by boot()."
},
{
"file": "tests/test_cc_sessions.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure."
},
{
"file": "tests/test_cc_sessions.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention."
},
{
"file": "tests/test_cc_sessions.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers."
},
{
"file": "tests/test_session_boot.py",
"standard": "architecture",
"reason": "Test files live in tests/, not in the 3-layer apps structure."
},
{
"file": "tests/test_session_boot.py",
"standard": "documentation",
"reason": "Test methods use descriptive names as documentation per pytest convention."
},
{
"file": "tests/test_session_boot.py",
"standard": "meta",
"reason": "Test files do not need Version/Modified metadata headers."
},
{
"file": "tests/test_session_boot.py",
"standard": "encapsulation",
"reason": "Tests import handlers directly to test implementation details."
}
],
"notes": {
+7 -4
View File
@@ -49,9 +49,11 @@ src/aipass/hooks/
│ ├── sound.py # Shared sound utilities (speak, play, mute)
│ ├── modules/
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
│ │ ├── cc_sessions.py # CC-native session file reader (~/.claude/sessions/<pid>.json)
│ │ ├── engine.py # Core dispatch — routes events to handlers
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
│ │ ├── presence.py # Branch presence — claim/release/refresh for .ai_central/PRESENCE.central.json
│ │ └── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator
│ ├── handlers/
│ │ ├── bridges/ # One per provider (thin normalization)
@@ -64,6 +66,7 @@ src/aipass/hooks/
│ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
│ │ │ ├── git_gate.py # Enforces git access tiers
│ │ │ ├── presence_gate.py # Single-session gate — blocks duplicate runtimes per branch
│ │ │ ├── rm_gate.py # Guardrail — catches accidental rm -rf, teaches drone rm
│ │ │ └── subagent_gate.py # Blocks sub-agent stop until clean
│ │ ├── lifecycle/ # Session management hooks
@@ -82,7 +85,7 @@ src/aipass/hooks/
│ └── diagnostics.py # JSONL logging for hook execution
├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
└── tests/ # 593 tests across 23 test files
└── tests/ # 705 tests across 25 test files
```
## How It Works
@@ -103,11 +106,11 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
| Event | Hooks | Description |
|---|---|---|
| UserPromptSubmit | identity, email, branch_loader, tier0_kernel, navmap | Prompt injection + inbox check |
| UserPromptSubmit | presence_gate, identity, email, branch_loader, tier0_kernel, navmap | Presence gate + prompt injection + inbox check |
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + guardrails + sound |
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
| SubagentStop | subagent_gate | Seedgo validation |
| Stop | stop_sound, telegram_response | Achievement bell + Telegram reply delivery |
| Stop | stop_sound, telegram_response, presence_release | Bell + Telegram delivery + presence release |
| Notification | announce | Announcement tone |
| PreCompact | compact, rollover | Memory archival + rollover |
@@ -151,7 +154,7 @@ The @drone broker validates sandbox policy before agent launch. @ai_mail's dispa
- All branches via hook dispatch — every Claude Code session routes through the engine
- @ai_mail dispatch_monitor — sandbox_launch + build_policy for agent launch boundary
*Last Updated: 2026-06-10*
*Last Updated: 2026-06-29*
---
@@ -53,9 +53,11 @@ def main() -> None:
hook_def = full_config.get(event_type, {}).get(hook_filter, {})
config = {"hooks_enabled": True, event_type: {hook_filter: hook_def}}
output = dispatch(event_type, stdin_data, config)
output, exit_code = dispatch(event_type, stdin_data, config)
if output:
sys.stdout.write(output)
if exit_code:
sys.exit(exit_code)
if __name__ == "__main__":
@@ -0,0 +1,190 @@
# =================== AIPass ====================
# Name: session_boot.py
# Version: 1.1.0
# Description: Boot wrapper — attach-if-live or start-in-tmux for Claude Code
# Branch: hooks
# Layer: apps/handlers/lifecycle
# Created: 2026-06-30
# Modified: 2026-06-30
# =============================================
"""Boot wrapper for Claude Code sessions.
When Patrick boots Claude in a branch directory, this wrapper:
1. If already inside tmux ($TMUX set) → execs claude directly (no nesting).
2. Checks CC-native ~/.claude/sessions/ for a live session at this cwd.
3. If live AND hosted in a tmux session → attaches to that tmux session.
4. If live but NOT in tmux → warns (can't inject into a plain terminal).
5. If no live session → starts fresh inside a new tmux session.
Tmux sessions are named after the branch directory (e.g., "hooks", "devpulse").
All sessions use --permission-mode bypassPermissions (TG user can't answer prompts).
Remote visibility is preserved (tmux composes with remoteControlAtStartup).
Entry points:
drone @hooks boot [claude args...]
"""
import os
import shutil
import subprocess
import sys
from pathlib import Path
from aipass.prax.apps.modules.logger import system_logger as logger
_DEFAULT_ARGS = ["--permission-mode", "bypassPermissions"]
def _resolve_claude_binary() -> str:
"""Resolve the REAL claude binary path from PATH.
Uses shutil.which, which searches the filesystem PATH — it does NOT see
shell functions. So even when a claude() shell function shadows the binary,
this finds the real one.
"""
path = shutil.which("claude")
if path:
return path
logger.error("[SESSION_BOOT] claude binary not found on PATH")
return "claude"
def _find_live_sessions(cwd: str) -> list[dict]:
"""Find live CC sessions for the given cwd via cc_sessions module."""
import importlib
cc_sessions = importlib.import_module("aipass.hooks.apps.modules.cc_sessions")
return cc_sessions.find_live_for_cwd(cwd)
def _find_tmux() -> str | None:
"""Find tmux binary on PATH."""
return shutil.which("tmux")
def _tmux_session_exists(name: str) -> bool:
"""Check if a tmux session with the given name exists."""
result = subprocess.run(
["tmux", "has-session", "-t", name],
capture_output=True,
text=True,
)
return result.returncode == 0
def _find_tmux_session_for_pid(pid: int) -> str | None:
"""Find which tmux session hosts the given PID (as a descendant of a pane)."""
result = subprocess.run(
["tmux", "list-panes", "-a", "-F", "#{pane_pid} #{session_name}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return None
for line in result.stdout.strip().splitlines():
parts = line.split(None, 1)
if len(parts) != 2:
continue
pane_pid_str, session_name = parts
try:
pane_pid = int(pane_pid_str)
except ValueError:
logger.info("[SESSION_BOOT] Non-integer pane PID: %s", pane_pid_str)
continue
if _is_descendant(pid, pane_pid):
return session_name
return None
def _is_descendant(target_pid: int, ancestor_pid: int) -> bool:
"""Check if target_pid is a descendant of ancestor_pid via /proc."""
pid = target_pid
for _ in range(20):
if pid == ancestor_pid:
return True
if pid <= 1:
return False
try:
for line in Path(f"/proc/{pid}/status").read_text().splitlines():
if line.startswith("PPid:"):
pid = int(line.split()[1])
break
else:
return False
except OSError as exc:
logger.info("[SESSION_BOOT] Cannot read /proc/%d/status: %s", pid, exc)
return False
return False
def boot(cwd: str | None = None, extra_args: list[str] | None = None) -> dict:
"""Boot Claude Code — attach if live, else start in tmux.
Args:
cwd: Branch directory (defaults to current working directory).
extra_args: Additional arguments to pass to claude (after default args).
Returns:
Result dict with action taken and details.
"""
cwd = cwd or str(Path.cwd().resolve())
branch = Path(cwd).name
claude_bin = _resolve_claude_binary()
if os.environ.get("TMUX"):
logger.info("[SESSION_BOOT] Already inside tmux — running claude directly")
claude_cmd = [claude_bin] + _DEFAULT_ARGS
if extra_args:
claude_cmd.extend(extra_args)
os.execvp(claude_bin, claude_cmd)
return {"exit_code": 0, "action": "direct", "reason": "already in tmux"}
tmux = _find_tmux()
if not tmux:
logger.error("[SESSION_BOOT] tmux not found on PATH")
return {"exit_code": 1, "error": "tmux not found — required for session hosting"}
live = _find_live_sessions(cwd)
if live:
session = live[0]
pid = session["pid"]
name = session.get("name", "")
logger.info("[SESSION_BOOT] Live session found: PID %d%s", pid, f" ({name})" if name else "")
tmux_session = _find_tmux_session_for_pid(pid)
if tmux_session:
logger.info("[SESSION_BOOT] Attaching to tmux session '%s'", tmux_session)
os.execvp("tmux", ["tmux", "attach-session", "-t", tmux_session])
return {"exit_code": 0, "action": "attached", "tmux_session": tmux_session}
return {
"exit_code": 1,
"action": "warn",
"error": f"Live session at PID {pid} is not in tmux. Kill it first or attach to its terminal.",
}
if _tmux_session_exists(branch):
logger.info("[SESSION_BOOT] Killing stale tmux session '%s'", branch)
subprocess.run(["tmux", "kill-session", "-t", branch], check=False)
claude_cmd = [claude_bin] + _DEFAULT_ARGS
if extra_args:
claude_cmd.extend(extra_args)
logger.info("[SESSION_BOOT] Starting fresh in tmux session '%s': %s", branch, " ".join(claude_cmd))
os.execvp("tmux", ["tmux", "new-session", "-s", branch, "--"] + claude_cmd)
return {"exit_code": 0, "action": "started", "tmux_session": branch}
def main() -> None:
"""CLI entry point for the boot wrapper."""
result = boot(extra_args=sys.argv[1:] if len(sys.argv) > 1 else None)
if result.get("exit_code", 0) != 0:
sys.stderr.write(f"{result.get('error', 'boot failed')}\n")
sys.exit(result["exit_code"])
if __name__ == "__main__":
main()
@@ -1,11 +1,11 @@
# =================== AIPass ====================
# Name: telegram_response.py
# Version: 1.0.0
# Version: 2.0.0
# Description: Telegram response delivery on Stop event (ported from Dev-Pass)
# Branch: hooks
# Layer: apps/handlers/notification
# Created: 2026-06-15
# Modified: 2026-06-15
# Modified: 2026-06-29
# =============================================
"""Telegram response delivery on Stop event.
@@ -18,6 +18,7 @@ Layer 2: isSidechain filter — skips sidechain entries during transcript extrac
Layer 3: Transcript position — only extracts text after the recorded injection point
"""
import hashlib
import json
import os
import re
@@ -30,12 +31,16 @@ from urllib.request import Request, urlopen
from aipass.prax.apps.modules.logger import system_logger as logger
PENDING_DIR = Path.home() / ".aipass" / "telegram_pending"
MIRROR_DIR = Path.home() / ".aipass" / "telegram_bots"
PENDING_TTL = 3600
TELEGRAM_CHAR_LIMIT = 4096
_DELIVERY_LOG = Path(__file__).resolve().parent.parent.parent.parent / "logs" / "telegram_delivery.jsonl"
def _is_expired(data: dict) -> bool:
"""Check if pending file is expired (1-hour TTL + tmux-alive check)."""
if data.get("mirror"):
return False
timestamp = data.get("timestamp", 0)
if isinstance(timestamp, str):
try:
@@ -73,38 +78,49 @@ def _try_load_pending(path: Path) -> dict | None:
return None
def _in_mirror_dir(path: Path) -> bool:
"""Check if a path is inside the persistent mirror mapping directory."""
try:
path.relative_to(MIRROR_DIR)
return True
except ValueError:
logger.info("[HOOKS] telegram: %s is not in mirror dir", path.name)
return False
def find_pending_file(session_id: str) -> Path | None: # noqa: ARG001
"""Find pending file matching current context via multi-bot matching.
Priority 1: AIPASS_BOT_ID env var -> bot-{bot_id}.json
Priority 2: CWD relative_to work_dir -> bot-*.json
Priority 1: AIPASS_BOT_ID env var -> bot-{bot_id}.json (mirror dir first, then pending)
Priority 2: CWD relative_to work_dir -> bot-*.json (both dirs)
"""
if not PENDING_DIR.exists():
return None
cwd = Path.cwd()
env_bot_id = os.environ.get("AIPASS_BOT_ID")
if env_bot_id:
v2_path = PENDING_DIR / f"bot-{env_bot_id}.json"
if _try_load_pending(v2_path) is not None:
logger.info("[HOOKS] telegram: v2 match bot_id env -> %s", v2_path.name)
return v2_path
for search_dir in [MIRROR_DIR, PENDING_DIR]:
path = search_dir / f"bot-{env_bot_id}.json"
if _try_load_pending(path) is not None:
logger.info("[HOOKS] telegram: match bot_id env -> %s", path)
return path
for pending_path in sorted(PENDING_DIR.glob("bot-*.json")):
data = _try_load_pending(pending_path)
if data is None:
continue
work_dir = data.get("work_dir")
if not work_dir:
continue
try:
cwd.relative_to(Path(work_dir))
logger.info("[HOOKS] telegram: v2 match cwd -> %s", pending_path.name)
return pending_path
except ValueError:
logger.info("[HOOKS] telegram: cwd not relative to %s, skipping", pending_path.name)
for search_dir in [PENDING_DIR, MIRROR_DIR]:
if not search_dir.exists():
continue
for pending_path in sorted(search_dir.glob("bot-*.json")):
data = _try_load_pending(pending_path)
if data is None:
continue
work_dir = data.get("work_dir")
if not work_dir:
continue
try:
cwd.relative_to(Path(work_dir))
logger.info("[HOOKS] telegram: match cwd -> %s", pending_path)
return pending_path
except ValueError:
logger.info("[HOOKS] telegram: cwd not relative to %s, skipping", pending_path.name)
continue
return None
@@ -192,6 +208,128 @@ def _collect_assistant_text(lines: list[str]) -> list[str]:
return text_parts
def _extract_user_text(content: list | str) -> str | None:
"""Extract text from user message content, returning None for tool-result-only messages."""
if isinstance(content, str):
return content.strip() or None
if not isinstance(content, list):
return None
texts: list[str] = []
has_non_tool = False
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "text":
text = block.get("text", "").strip()
if text:
texts.append(text)
has_non_tool = True
elif block.get("type") != "tool_result":
has_non_tool = True
if not has_non_tool:
return None
return " ".join(texts) if texts else None
def _text_from_content(content: list) -> list[str]:
"""Extract text strings from a message content block list."""
if not isinstance(content, list):
return []
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text", "").strip()
if text:
parts.append(text)
return parts
def _collect_mirror_entries(lines: list[str]) -> list[tuple[str | None, list[str]]]:
"""Collect user+assistant turn pairs from JSONL lines for mirror delivery."""
turns: list[tuple[str | None, list[str]]] = []
current_user: str | None = None
current_assistant: list[str] = []
for line in lines:
try:
entry = json.loads(line)
except json.JSONDecodeError:
logger.info("[HOOKS] telegram: skipping malformed JSONL line in mirror collection")
continue
if entry.get("isSidechain", False):
continue
entry_type = entry.get("type")
message = entry.get("message", {})
content = message.get("content", [])
if entry_type == "user":
user_text = _extract_user_text(content)
if user_text is None:
continue
if current_user is not None or current_assistant:
turns.append((current_user, current_assistant))
current_user = user_text
current_assistant = []
elif entry_type == "assistant":
current_assistant.extend(_text_from_content(content))
if current_user is not None or current_assistant:
turns.append((current_user, current_assistant))
return turns
def extract_mirror_turn(transcript_path: str, start_line: int = 0) -> str | None:
"""Extract all new turns (user input + assistant response) for mirror delivery."""
path = Path(transcript_path)
if not path.exists():
logger.warning("[HOOKS] telegram: mirror transcript not found: %s", transcript_path)
return None
try:
all_lines = path.read_text(encoding="utf-8").strip().split("\n")
except OSError as e:
logger.error("[HOOKS] telegram: failed to read mirror transcript: %s", e)
return None
if not all_lines:
return None
lines = all_lines[start_line:] if start_line > 0 else all_lines
if not lines and start_line > len(all_lines):
logger.warning(
"[HOOKS] telegram: cursor ahead of transcript (%d > %d) — clamping to deliver latest",
start_line,
len(all_lines),
)
clamped = _find_last_real_user_message(all_lines)
lines = all_lines[max(0, clamped) :]
if not lines:
return None
turns = _collect_mirror_entries(lines)
if not turns:
return None
formatted = []
for user_text, assistant_parts in turns:
parts: list[str] = []
if user_text:
parts.append(f"You: {user_text}")
if assistant_parts:
parts.append("\n\n".join(assistant_parts))
if parts:
formatted.append("\n\n".join(parts))
if not formatted:
return None
result = "\n\n---\n\n".join(formatted).strip()
return result if result else None
def chunk_text(text: str, limit: int = TELEGRAM_CHAR_LIMIT) -> list[str]:
"""Split text into chunks for Telegram's message limit."""
if len(text) <= limit:
@@ -278,8 +416,14 @@ def markdown_to_telegram_html(text: str) -> str:
return text
def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int | None = None) -> bool:
"""Send a message to Telegram via Bot API using urllib."""
def _parse_api_message(api_result: dict) -> dict:
"""Extract message_id and text from a Telegram API response."""
msg = api_result.get("result", {})
return {"ok": True, "message_id": msg.get("message_id"), "text": msg.get("text", "")}
def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int | None = None) -> dict:
"""Send a message to Telegram via Bot API. Returns dict with ok, message_id, text."""
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
try:
@@ -290,10 +434,10 @@ def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int |
data = json.dumps(html_payload).encode("utf-8")
req = Request(url, data=data, headers={"Content-Type": "application/json"})
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read())
if result.get("ok"):
return True
logger.warning("[HOOKS] telegram: HTML send failed: %s", result.get("description"))
api_result = json.loads(resp.read())
if api_result.get("ok"):
return _parse_api_message(api_result)
logger.warning("[HOOKS] telegram: HTML send failed: %s", api_result.get("description"))
except Exception as e:
logger.warning("[HOOKS] telegram: HTML send error: %s, plain text fallback", e)
@@ -306,11 +450,11 @@ def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int |
try:
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read())
if result.get("ok"):
return True
logger.error("[HOOKS] telegram: API error: %s", result.get("description"))
return False
api_result = json.loads(resp.read())
if api_result.get("ok"):
return _parse_api_message(api_result)
logger.error("[HOOKS] telegram: API error: %s", api_result.get("description"))
return {"ok": False}
except HTTPError as e:
try:
body = json.loads(e.read().decode("utf-8"))
@@ -318,17 +462,17 @@ def send_to_telegram(bot_token: str, chat_id: int, text: str, message_id: int |
logger.error("[HOOKS] telegram: HTTP %d: %s (len=%d)", e.code, description, len(text))
except Exception:
logger.error("[HOOKS] telegram: HTTP %d: %s (len=%d)", e.code, e.reason, len(text))
return False
return {"ok": False}
except URLError as e:
logger.error("[HOOKS] telegram: send failed: %s", e)
return False
return {"ok": False}
except Exception as e:
logger.error("[HOOKS] telegram: unexpected send error: %s", e)
return False
return {"ok": False}
def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: str) -> bool:
"""Edit an existing Telegram message via Bot API."""
def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: str) -> dict:
"""Edit an existing Telegram message via Bot API. Returns dict with ok, message_id, text."""
url = f"https://api.telegram.org/bot{bot_token}/editMessageText"
try:
@@ -337,9 +481,9 @@ def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: s
data = json.dumps(html_payload).encode("utf-8")
req = Request(url, data=data, headers={"Content-Type": "application/json"})
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read())
if result.get("ok", False):
return True
api_result = json.loads(resp.read())
if api_result.get("ok", False):
return _parse_api_message(api_result)
logger.warning("[HOOKS] telegram: HTML edit failed, plain text fallback")
except Exception as e:
logger.warning("[HOOKS] telegram: HTML edit error: %s, plain text fallback", e)
@@ -350,23 +494,26 @@ def edit_telegram_message(bot_token: str, chat_id: int, message_id: int, text: s
try:
with urlopen(req, timeout=15) as resp:
result = json.loads(resp.read())
return result.get("ok", False)
api_result = json.loads(resp.read())
if api_result.get("ok", False):
return _parse_api_message(api_result)
return {"ok": False}
except Exception as e:
logger.warning("[HOOKS] telegram: edit failed: %s", e)
return False
return {"ok": False}
def _send_with_retry(bot_token: str, chat_id: int, text: str, retries: int = 3) -> bool:
"""Send with retry and exponential backoff."""
def _send_with_retry(bot_token: str, chat_id: int, text: str, retries: int = 3) -> dict:
"""Send with retry and exponential backoff. Returns dict with ok, message_id, text."""
for attempt in range(retries):
if send_to_telegram(bot_token, chat_id, text):
return True
result = send_to_telegram(bot_token, chat_id, text)
if result["ok"]:
return result
if attempt < retries - 1:
delay = 1.0 * (2**attempt)
logger.info("[HOOKS] telegram: send retry %d/%d after %.0fs", attempt + 2, retries, delay)
time.sleep(delay)
return False
return {"ok": False}
def handle(hook_data: dict) -> dict:
@@ -394,16 +541,19 @@ def handle(hook_data: dict) -> dict:
pending_data = json.loads(pending_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
logger.error("[HOOKS] telegram: failed to read pending: %s", e)
pending_file.unlink(missing_ok=True)
if not _in_mirror_dir(pending_file):
pending_file.unlink(missing_ok=True)
return {"stdout": "", "exit_code": 0}
is_mirror = pending_data.get("mirror", False)
chat_id = pending_data.get("chat_id")
bot_token = pending_data.get("bot_token")
processing_message_id = pending_data.get("processing_message_id")
if not chat_id or not bot_token:
logger.error("[HOOKS] telegram: missing chat_id or bot_token in pending")
pending_file.unlink(missing_ok=True)
if not is_mirror:
pending_file.unlink(missing_ok=True)
return {"stdout": "", "exit_code": 0}
response_text = _extract_response(hook_data, transcript_path, pending_data)
@@ -417,11 +567,12 @@ def handle(hook_data: dict) -> dict:
chunks = chunk_text(response_text)
logger.info("[HOOKS] telegram: sending %d chunk(s) (logs_active=%s)", len(chunks), logs_were_active)
all_sent = _deliver_chunks(chunks, bot_token, chat_id, processing_message_id, logs_were_active)
all_sent, chunk_results = _deliver_chunks(chunks, bot_token, chat_id, processing_message_id, logs_were_active)
_write_delivery_log(response_text, chunks, chunk_results, session_id)
if all_sent:
pending_file.unlink(missing_ok=True)
logger.info("[HOOKS] telegram: response delivered, pending cleaned")
_advance_pending(pending_file, pending_data, transcript_path)
else:
logger.error("[HOOKS] telegram: delivery failed — keeping pending for retry")
@@ -430,6 +581,9 @@ def handle(hook_data: dict) -> dict:
def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict) -> str | None:
"""Try JSONL transcript extraction with retries, fall back to last_assistant_message."""
if pending_data.get("mirror"):
return _extract_mirror_response(transcript_path, pending_data)
response_text = None
if transcript_path:
@@ -446,7 +600,7 @@ def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict)
logger.info("[HOOKS] telegram: JSONL retry %.1fs (attempt %d/3)", delay, attempt + 1)
time.sleep(delay)
if not response_text:
if not response_text and not pending_data.get("delivered"):
response_text = (hook_data.get("last_assistant_message") or "").strip()
if response_text:
logger.info("[HOOKS] telegram: using last_assistant_message fallback (%d chars)", len(response_text))
@@ -454,6 +608,23 @@ def _extract_response(hook_data: dict, transcript_path: str, pending_data: dict)
return response_text or None
def _extract_mirror_response(transcript_path: str, pending_data: dict) -> str | None:
"""Extract all new turns for mirror delivery with retries."""
if not transcript_path:
return None
start_line = pending_data.get("transcript_line_after", 0)
for attempt in range(3):
result = extract_mirror_turn(transcript_path, start_line)
if result:
logger.info("[HOOKS] telegram: mirror extraction: %d chars (attempt %d)", len(result), attempt + 1)
return result
if attempt < 2:
delay = [0.2, 0.5][attempt]
logger.info("[HOOKS] telegram: mirror retry %.1fs (attempt %d/3)", delay, attempt + 1)
time.sleep(delay)
return None
def _prepend_branch_prefix(text: str) -> str:
"""Prepend @branch identifier so user knows which branch responded."""
try:
@@ -486,22 +657,110 @@ def _deliver_chunks(
chat_id: int,
processing_message_id: int | None,
logs_were_active: bool,
) -> bool:
"""Send all response chunks to Telegram. Returns True if all succeeded."""
) -> tuple[bool, list[dict]]:
"""Send all response chunks to Telegram. Returns (all_sent, per-chunk results)."""
chunk_results: list[dict] = []
all_sent = True
single = len(chunks) == 1
for i, chunk in enumerate(chunks):
if i == 0 and processing_message_id and not logs_were_active:
text = f"[1/{len(chunks)}]\n{chunk}" if len(chunks) > 1 else chunk
if not edit_telegram_message(bot_token, chat_id, processing_message_id, text):
if not _send_with_retry(bot_token, chat_id, text):
if i == 0 and processing_message_id:
if single and not logs_were_active:
result = edit_telegram_message(bot_token, chat_id, processing_message_id, chunk)
if result["ok"]:
chunk_results.append({"idx": i, "method": "edit", **result})
continue
result = _send_with_retry(bot_token, chat_id, chunk)
chunk_results.append({"idx": i, "method": "send", **result})
if not result["ok"]:
all_sent = False
elif i == 0 and processing_message_id and logs_were_active:
continue
edit_telegram_message(bot_token, chat_id, processing_message_id, "Done.")
text = f"[1/{len(chunks)}]\n{chunk}" if len(chunks) > 1 else chunk
if not _send_with_retry(bot_token, chat_id, text):
all_sent = False
prefix = f"[{i + 1}/{len(chunks)}]\n" if not single else ""
result = _send_with_retry(bot_token, chat_id, prefix + chunk)
chunk_results.append({"idx": i, "method": "send", **result})
if not result["ok"]:
all_sent = False
return all_sent, chunk_results
def _advance_pending(pending_file: Path, pending_data: dict, transcript_path: str) -> None:
"""Advance transcript cursor so later Stops can deliver new text."""
is_mirror = pending_data.get("mirror", False)
if not transcript_path:
if not is_mirror:
pending_file.unlink(missing_ok=True)
logger.info("[HOOKS] telegram: no transcript — pending %s", "kept (mirror)" if is_mirror else "removed")
return
try:
line_count = 0
with open(transcript_path, encoding="utf-8") as f:
for _ in f:
line_count += 1
pending_data["transcript_line_after"] = line_count
pending_data["delivered"] = True
pending_file.write_text(json.dumps(pending_data, indent=2), encoding="utf-8")
logger.info("[HOOKS] telegram: cursor advanced to line %d", line_count)
except OSError as e:
logger.warning("[HOOKS] telegram: cursor advance failed: %s", e)
if not is_mirror:
pending_file.unlink(missing_ok=True)
def _write_delivery_log(intended_text: str, chunks: list[str], chunk_results: list[dict], session_id: str) -> None:
"""Write JSONL record documenting what was delivered to Telegram."""
intended_sha = hashlib.sha256(intended_text.encode("utf-8")).hexdigest()[:16]
delivered_parts = []
for cr in chunk_results:
text = cr.get("text") or ""
text = re.sub(r"^\[\d+/\d+\]\n", "", text)
delivered_parts.append(text)
delivered_concat = "\n\n".join(delivered_parts)
delivered_sha = hashlib.sha256(delivered_concat.encode("utf-8")).hexdigest()[:16]
all_ok = all(cr.get("ok") for cr in chunk_results)
match = all_ok and intended_sha == delivered_sha
culprit = None
if not match:
failed = [cr["idx"] for cr in chunk_results if not cr.get("ok")]
if failed:
culprit = f"delivery_failed: chunks {failed}"
elif abs(len(intended_text) - len(delivered_concat)) > max(len(intended_text) * 0.1, 20):
culprit = f"length_mismatch: intended={len(intended_text)} delivered={len(delivered_concat)}"
else:
prefix = f"[{i + 1}/{len(chunks)}]\n" if len(chunks) > 1 else ""
if not _send_with_retry(bot_token, chat_id, prefix + chunk):
all_sent = False
return all_sent
culprit = "formatting_conversion"
record = {
"ts": time.time(),
"session": session_id[:8] if session_id else "",
"intended_text": intended_text,
"intended_sha256": intended_sha,
"intended_len": len(intended_text),
"chunks": [
{
"idx": cr.get("idx"),
"method": cr.get("method"),
"message_id": cr.get("message_id"),
"ok": cr.get("ok"),
"returned_len": len(cr.get("text") or ""),
}
for cr in chunk_results
],
"delivered_concat": delivered_concat,
"delivered_sha256": delivered_sha,
"delivered_len": len(delivered_concat),
"match": match,
}
if culprit:
record["culprit"] = culprit
try:
_DELIVERY_LOG.parent.mkdir(parents=True, exist_ok=True)
with open(_DELIVERY_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(record) + "\n")
except OSError as e:
logger.warning("[HOOKS] telegram: delivery log write failed: %s", e)

Some files were not shown because too many files have changed in this diff Show More