From c76dd81c7d2e581ab7069aec2eba2846272e5704 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:32:50 +0200 Subject: [PATCH 1/6] v9.4.3 hide completed tasks by default --- src/postmaster/scheduler_engine.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/postmaster/scheduler_engine.py b/src/postmaster/scheduler_engine.py index a2a78d1..13c6f46 100644 --- a/src/postmaster/scheduler_engine.py +++ b/src/postmaster/scheduler_engine.py @@ -669,7 +669,7 @@ def get_job(self, job_id: str) -> dict[str, Any]: with self._connect() as conn: row = conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone() if not row: - raise SchedulerError(f"Unknown job: {job_id}") + raise SchedulerError(f"Job not found: {job_id}") return self._row_to_job(row) def list_jobs( @@ -679,6 +679,7 @@ def list_jobs( project_id: str | None = None, status: str | None = None, limit: int = 200, + include_completed: bool = False, ) -> list[dict[str, Any]]: limit = max(1, min(limit, 1000)) q = "SELECT * FROM jobs" @@ -695,6 +696,9 @@ def list_jobs( raise SchedulerError(f"Unknown status: {status}") clauses.append("status=?") args.append(status) + elif not include_completed: + clauses.append("status<>?") + args.append("completed") if clauses: q += " WHERE " + " AND ".join(clauses) q += " ORDER BY COALESCE(next_run_utc, '9999') ASC, created_at DESC LIMIT ?" From 3b05f52ddc5f1f4287418f2c558b7d907c26b17c Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:33:39 +0200 Subject: [PATCH 2/6] v9.4.3 expose task detail and structured list tools --- src/postmaster/runtime.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/postmaster/runtime.py b/src/postmaster/runtime.py index b9e6d29..0e645fa 100644 --- a/src/postmaster/runtime.py +++ b/src/postmaster/runtime.py @@ -42,6 +42,8 @@ def build_status(): status["explicit_reply_follow_up_modes"] = True status["follow_up_email"] = True status["follow_up_draft"] = True + status["task_detail_view"] = True + status["completed_tasks_hidden_by_default"] = True return status mcp.remove_tool("build_status") @@ -49,6 +51,37 @@ def build_status(): _base.build_status = build_status +def list_jobs( + owner_id: str | None = None, + project_id: str | None = None, + status: str | None = None, + limit: int = 200, + include_completed: bool = False, +): + """Read-only. List registered tasks. Completed tasks are hidden unless explicitly requested.""" + rows = _base._safe_call( + _base.scheduler().list_jobs, + owner_id=owner_id, + project_id=project_id, + status=status, + limit=limit, + include_completed=include_completed, + ) + if isinstance(rows, dict) and rows.get("ok") is False: + return {"ok": False, "error": rows.get("error", "Unable to list jobs"), "count": 0, "jobs": []} + return {"ok": True, "count": len(rows), "jobs": rows} + +mcp.remove_tool("list_jobs") +mcp.add_tool(list_jobs, name="list_jobs") +_base.list_jobs = list_jobs + + +@mcp.tool() +def get_job(job_id: str): + """Read-only. Return the complete stored record for one registered task.""" + return _base._safe_call(_base.scheduler().get_job, job_id) + + @mcp.tool() def follow_up_email( mailbox: str, From 07d91778c5af209dc4985ef6215a14031975fac2 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:34:44 +0200 Subject: [PATCH 3/6] v9.4.3 add task visibility regression coverage --- tests/test_v9_4_3_task_visibility.py | 332 +++++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 tests/test_v9_4_3_task_visibility.py diff --git a/tests/test_v9_4_3_task_visibility.py b/tests/test_v9_4_3_task_visibility.py new file mode 100644 index 0000000..6a80ad3 --- /dev/null +++ b/tests/test_v9_4_3_task_visibility.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +import asyncio +import json +import os +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from mcp import Client +from mcp.types import TextContent + +from postmaster.scheduler_engine import SchedulerEngine, SchedulerError, SchedulerSettings + + +class V943TaskVisibilityTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + self.engine = SchedulerEngine( + SchedulerSettings( + db_path=str(self.root / "scheduler.db"), + default_owner_id="owner-a", + default_owner_name="Owner A", + seed_tinkerer_project=False, + seed_tinkerer_profile=False, + ) + ) + self.engine.create_project( + owner_id="owner-a", project_id="project-a", name="Project A" + ) + self.engine.create_project( + owner_id="owner-a", project_id="project-b", name="Project B" + ) + self.engine.create_owner("owner-b", "Owner B") + self.engine.create_project( + owner_id="owner-b", project_id="project-c", name="Project C" + ) + + def tearDown(self) -> None: + self.tmp.cleanup() + + @staticmethod + def _future_once(hours: int = 2) -> str: + return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat() + + def _create_job( + self, + *, + owner_id: str = "owner-a", + project_id: str = "project-a", + title: str = "Task", + schedule_type: str = "interval", + schedule_value: str = "3600", + payload: dict | None = None, + execution_profile_id: str | None = None, + ) -> dict: + return self.engine.create_job( + owner_id=owner_id, + project_id=project_id, + title=title, + description=f"Description for {title}", + action_type="reminder", + execution_profile_id=execution_profile_id, + payload=payload or {"kind": "regression"}, + schedule_type=schedule_type, + schedule_value=schedule_value, + timezone="Europe/Rome", + approval_mode="approval_required", + ) + + def _set_job_fields(self, job_id: str, **fields) -> None: + assignments = ", ".join(f"{name}=?" for name in fields) + with self.engine._connect() as conn: + conn.execute( + f"UPDATE jobs SET {assignments} WHERE id=?", + [*fields.values(), job_id], + ) + + def _force_due(self, job_id: str) -> None: + self._set_job_fields( + job_id, + next_run_utc=(datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat(), + ) + + def _mark_completed(self, job_id: str, created_at: str | None = None) -> None: + fields = { + "status": "completed", + "next_run_utc": None, + "last_run_utc": datetime.now(timezone.utc).isoformat(), + } + if created_at is not None: + fields["created_at"] = created_at + fields["updated_at"] = created_at + self._set_job_fields(job_id, **fields) + + def test_list_jobs_hides_completed_by_default_and_can_include_them(self) -> None: + active = self._create_job(title="Active") + completed = self._create_job(title="Completed") + self._mark_completed(completed["id"]) + + default_rows = self.engine.list_jobs() + self.assertEqual([row["id"] for row in default_rows], [active["id"]]) + + all_rows = self.engine.list_jobs(include_completed=True) + self.assertEqual({row["id"] for row in all_rows}, {active["id"], completed["id"]}) + + completed_rows = self.engine.list_jobs(status="completed") + self.assertEqual([row["id"] for row in completed_rows], [completed["id"]]) + + def test_explicit_non_completed_status_filter_still_works(self) -> None: + scheduled = self._create_job(title="Scheduled") + paused = self._create_job(title="Paused") + completed = self._create_job(title="Completed") + self._set_job_fields(paused["id"], status="paused") + self._mark_completed(completed["id"]) + + scheduled_rows = self.engine.list_jobs(status="scheduled", include_completed=True) + self.assertEqual([row["id"] for row in scheduled_rows], [scheduled["id"]]) + paused_rows = self.engine.list_jobs(status="paused") + self.assertEqual([row["id"] for row in paused_rows], [paused["id"]]) + + def test_owner_project_filters_combine_with_completed_visibility(self) -> None: + a_active = self._create_job(owner_id="owner-a", project_id="project-a", title="A active") + a_done = self._create_job(owner_id="owner-a", project_id="project-a", title="A done") + b_done = self._create_job(owner_id="owner-a", project_id="project-b", title="B done") + c_done = self._create_job(owner_id="owner-b", project_id="project-c", title="C done") + for row in (a_done, b_done, c_done): + self._mark_completed(row["id"]) + + scoped_default = self.engine.list_jobs(owner_id="owner-a", project_id="project-a") + self.assertEqual([row["id"] for row in scoped_default], [a_active["id"]]) + + scoped_all = self.engine.list_jobs( + owner_id="owner-a", project_id="project-a", include_completed=True + ) + self.assertEqual({row["id"] for row in scoped_all}, {a_active["id"], a_done["id"]}) + + scoped_done = self.engine.list_jobs( + owner_id="owner-a", project_id="project-a", status="completed" + ) + self.assertEqual([row["id"] for row in scoped_done], [a_done["id"]]) + + def test_limit_is_applied_after_completed_visibility_filter(self) -> None: + visible_one = self._create_job(title="Visible one") + visible_two = self._create_job(title="Visible two") + hidden = [self._create_job(title=f"Hidden {index}") for index in range(3)] + + for row in (visible_one, visible_two): + self._set_job_fields( + row["id"], + status="paused", + next_run_utc=None, + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:00+00:00", + ) + for index, row in enumerate(hidden): + self._mark_completed(row["id"], f"2026-12-0{index + 1}T00:00:00+00:00") + + rows = self.engine.list_jobs(limit=2) + self.assertEqual(len(rows), 2) + self.assertEqual({row["id"] for row in rows}, {visible_one["id"], visible_two["id"]}) + + def test_get_job_returns_complete_record_and_clear_not_found(self) -> None: + self.engine.create_execution_profile( + owner_id="owner-a", + project_id="project-a", + profile_id="profile-a", + provider="generic", + identity="operator", + description="Regression profile", + ) + created = self._create_job( + title="Detailed task", + payload={"nested": {"value": 7}}, + execution_profile_id="profile-a", + ) + detail = self.engine.get_job(created["id"]) + required = { + "id", "owner_id", "project_id", "title", "description", "action_type", + "execution_profile_id", "schedule_type", "schedule_value", "timezone", + "approval_mode", "status", "next_run_utc", "created_at", "updated_at", + "last_run_utc", "last_error", "payload", + } + self.assertTrue(required.issubset(detail.keys())) + self.assertEqual(detail["execution_profile_id"], "profile-a") + self.assertEqual(detail["payload"], {"nested": {"value": 7}}) + self.assertNotIn("payload_json", detail) + + with self.assertRaisesRegex(SchedulerError, "Job not found"): + self.engine.get_job("job_missing") + + def test_completed_records_remain_stored_and_scheduler_status_counts_them(self) -> None: + job = self._create_job( + title="One shot", + schedule_type="once", + schedule_value=self._future_once(), + ) + self._force_due(job["id"]) + completed = self.engine.complete_job(job["id"], note="handled") + self.assertEqual(completed["status"], "completed") + self.assertIsNone(completed["next_run_utc"]) + self.assertEqual(self.engine.get_job(job["id"])["status"], "completed") + self.assertEqual(self.engine.status()["job_counts"].get("completed"), 1) + with self.engine._connect() as conn: + stored = conn.execute("SELECT COUNT(*) FROM jobs WHERE id=?", (job["id"],)).fetchone()[0] + self.assertEqual(stored, 1) + self.assertEqual(self.engine.list_jobs(), []) + self.assertEqual([row["id"] for row in self.engine.list_jobs(status="completed")], [job["id"]]) + + def test_create_complete_due_and_recurring_advancement_do_not_regress(self) -> None: + once = self._create_job( + title="Due once", + schedule_type="once", + schedule_value=self._future_once(), + payload={"source": "create-regression"}, + ) + self.assertEqual(once["status"], "scheduled") + self.assertEqual(once["payload"], {"source": "create-regression"}) + self._force_due(once["id"]) + self.assertEqual([row["id"] for row in self.engine.list_due_jobs()], [once["id"]]) + once_done = self.engine.complete_job(once["id"], note="once complete") + self.assertEqual(once_done["status"], "completed") + self.assertNotIn(once["id"], [row["id"] for row in self.engine.list_due_jobs()]) + + recurring = self._create_job(title="Recurring", schedule_type="interval", schedule_value="3600") + self._force_due(recurring["id"]) + recurring_done = self.engine.complete_job(recurring["id"], note="advance") + self.assertEqual(recurring_done["status"], "scheduled") + self.assertIsNotNone(recurring_done["next_run_utc"]) + self.assertIsNotNone(recurring_done["last_run_utc"]) + self.assertGreater( + datetime.fromisoformat(recurring_done["next_run_utc"]), + datetime.now(timezone.utc), + ) + + def test_mcp_tools_expose_structured_list_get_job_and_capabilities(self) -> None: + runtime_root = self.root / "runtime" + runtime_root.mkdir() + old_scheduler_db = os.environ.get("SCHEDULER_DB_PATH") + os.environ["SCHEDULER_DB_PATH"] = str(runtime_root / "scheduler.db") + try: + import postmaster.runtime as runtime + + runtime.scheduler.cache_clear() + runtime_engine = runtime.scheduler() + runtime_engine.create_project( + owner_id=runtime_engine.settings.default_owner_id, + project_id="runtime-project", + name="Runtime project", + ) + visible = runtime_engine.create_job( + owner_id=runtime_engine.settings.default_owner_id, + project_id="runtime-project", + title="Runtime visible", + description="MCP serialization", + action_type="reminder", + execution_profile_id=None, + payload={"mcp": True}, + schedule_type="interval", + schedule_value="3600", + timezone="Europe/Rome", + approval_mode="approval_required", + ) + hidden = runtime_engine.create_job( + owner_id=runtime_engine.settings.default_owner_id, + project_id="runtime-project", + title="Runtime completed", + description="Hidden by default", + action_type="reminder", + execution_profile_id=None, + payload={"mcp": True}, + schedule_type="interval", + schedule_value="3600", + timezone="Europe/Rome", + approval_mode="approval_required", + ) + with runtime_engine._connect() as conn: + conn.execute( + "UPDATE jobs SET status='completed', next_run_utc=NULL WHERE id=?", + (hidden["id"],), + ) + + async def exercise_mcp(): + async with Client(runtime.mcp, raise_exceptions=True) as client: + tools = await client.list_tools() + listed = await client.call_tool("list_jobs", {}) + included = await client.call_tool("list_jobs", {"include_completed": True}) + completed = await client.call_tool("list_jobs", {"status": "completed"}) + detail = await client.call_tool("get_job", {"job_id": hidden["id"]}) + missing = await client.call_tool("get_job", {"job_id": "job_missing"}) + return tools, listed, included, completed, detail, missing + + tools, listed, included, completed, detail, missing = asyncio.run(exercise_mcp()) + tool_map = {tool.name: tool for tool in tools.tools} + self.assertIn("get_job", tool_map) + self.assertIn("include_completed", tool_map["list_jobs"].input_schema["properties"]) + + def payload(result): + self.assertFalse(result.is_error) + self.assertEqual(len(result.content), 1) + self.assertIsInstance(result.content[0], TextContent) + return json.loads(result.content[0].text) + + default_payload = payload(listed) + self.assertEqual(default_payload["count"], 1) + self.assertEqual([row["id"] for row in default_payload["jobs"]], [visible["id"]]) + self.assertEqual(payload(included)["count"], 2) + self.assertEqual([row["id"] for row in payload(completed)["jobs"]], [hidden["id"]]) + self.assertEqual(payload(detail)["id"], hidden["id"]) + missing_payload = payload(missing) + self.assertFalse(missing_payload["ok"]) + self.assertIn("not found", missing_payload["error"].lower()) + + status = runtime.build_status() + self.assertTrue(status["task_detail_view"]) + self.assertTrue(status["completed_tasks_hidden_by_default"]) + finally: + try: + import postmaster.runtime as runtime + runtime.scheduler.cache_clear() + except Exception: + pass + if old_scheduler_db is None: + os.environ.pop("SCHEDULER_DB_PATH", None) + else: + os.environ["SCHEDULER_DB_PATH"] = old_scheduler_db + + +if __name__ == "__main__": + unittest.main() From 280f465002926e756a521e6b722b2147c4d8b380 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:36:00 +0200 Subject: [PATCH 4/6] v9.4.3 bump release version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 3c40359..3200162 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -9.4.2 +9.4.3 From 71dec068dec37acdb55ece5b5ad7586320e8315c Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:36:40 +0200 Subject: [PATCH 5/6] v9.4.3 document task visibility release --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f7715..f8da5d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ Postmaster MCP follows Semantic Versioning for stable releases. Every stable release should update `VERSION`, this changelog, and publish an immutable Git tag/release named `vX.Y.Z`. +## 9.4.3 - 2026-08-20 + +### Added +- New read-only MCP tool `get_job(job_id)` as the task-detail equivalent of `get_memory` / `get_skill`, returning the complete stored task record including schedule, status, timestamps, errors, execution-profile reference and decoded payload. +- `list_jobs(..., include_completed=False)` visibility control. Completed tasks are hidden by default, while `include_completed=true` restores the combined active + completed view and an explicit `status="completed"` filter always returns completed tasks. +- Structured MCP list serialization for tasks as one `{ok, count, jobs}` result instead of a concatenation of individual JSON objects. Existing job record fields are preserved inside `jobs` for compatibility. +- `build_status.task_detail_view=true` and `build_status.completed_tasks_hidden_by_default=true` capability reporting. +- Regression coverage for default/explicit completed visibility, status and owner/project filters, post-filter limits, full task detail, not-found handling, persistence/counting of completed records, due-task behavior, create/complete behavior, recurring advancement and MCP serialization. + +### Changed +- `list_jobs()` now treats `completed` as a read-time visibility filter only. The persisted status remains `completed`; completed records are not renamed, deleted, archived or migrated. +- The task-list `limit` is applied after completed-task visibility and all explicit owner/project/status filters, so hidden completed rows cannot consume the requested result limit. +- The dashboard's normal task listing inherits the same default completed-task hiding through the shared scheduler list implementation. + +### Compatibility / deployment +- `create_job`, `complete_job`, recurring schedule advancement, `list_due_jobs`, approval/security behavior, task persistence and registry-only scheduler execution semantics are unchanged. `scheduler_status` continues to count completed records. +- No scheduler database migration is required and existing task records/payloads remain readable through `get_job`. +- `postmaster-mcp.yml` remains unchanged: no new environment variables, ports, volumes, bootstrap logic or Cloudflare changes are required. +- Deployments using `POSTMASTER_VERSION=latest` with update checks enabled can select v9.4.3 through the normal restart/redeploy after the stable release is published. + ## 9.4.2 - 2026-08-20 ### Added From da1fc2f511dea2233fbb588f4e2cc0982fedb10b Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:37:50 +0200 Subject: [PATCH 6/6] v9.4.3 document task list and detail UX --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b1f90bf..af05805 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ The important v9 changes are: - improved MIME parsing for forwarded mail and HTML-heavy messages; - CI coverage for the bootstrap, MIME parser, knowledge store and semantic-model provisioning; - persistent small-file storage plus native ChatGPT file inputs in v9.2; +- task list/detail UX with completed tasks hidden by default and explicit retrieval in v9.4.3; - semantic release history through `VERSION`, `CHANGELOG.md` and immutable `vX.Y.Z` release tags. --- @@ -449,7 +450,18 @@ Review Junk and restore genuine false positives. Check unread mail and summarize messages requiring attention. ``` -The server persists the task state; the AI client performs the reasoning and explicit action. +From v9.4.3 the task read UX mirrors the Memory/Skill list/detail pattern. Completed tasks remain stored with the persistent status `completed`, but they are hidden from the normal list unless the caller asks for them explicitly: + +```text +list_jobs() -> non-completed tasks only +list_jobs(include_completed=true) -> non-completed + completed tasks +list_jobs(status="completed") -> completed tasks explicitly +get_job(job_id) -> complete record for one task +``` + +`list_jobs` returns one structured MCP result with `{ok, count, jobs}`. The individual job objects keep their existing fields for compatibility, while `get_job` is the full detail view with owner/project, description, action type, execution profile, schedule, approval mode, status, timestamps, last error and payload. Owner/project/status filters still combine normally, and the result `limit` is applied after completed tasks are excluded. + +Hiding a completed task is presentation only: no record is renamed to `done`, archived, deleted or migrated. `scheduler_status` still counts completed tasks, and `get_job` can read a completed task directly by ID. The server persists the task state; the AI client performs the reasoning and explicit action. --- @@ -710,3 +722,9 @@ See `docs/LINK_TRACKING.md` for architecture, schema, Sent-clean behavior, analy v9.4.2 prevents outbound messages from accidentally being replied back to the sender account. `reply_email` / `create_reply_draft` are inbound-only semantics, while `follow_up_email` / `create_follow_up_draft` operate on outbound/Sent messages and reuse the original visible recipients after sender-identity filtering. Source Bcc is never recovered. Tracked follow-ups reuse the v9.4 dual-MIME pipeline: recipient copies may contain the configured open/link instrumentation, while archived Sent copies keep original URLs and omit active recipient pixel, click-tracking URLs and recipient AMP callbacks. Visible `To` / `Cc`, threading headers and attachment bytes remain consistent. No new environment variables, ports, volumes, callback paths or Portainer YAML changes are required. + +# Task list/detail UX (v9.4.3) + +v9.4.3 makes task reads behave more like persistent Memory and Skill reads. `list_jobs()` is now the scannable list view and hides stored `completed` tasks by default; callers can opt back into all records with `include_completed=true` or request completed tasks directly with `status="completed"`. The persisted database value remains `completed` and is never renamed to `done`. + +`get_job(job_id)` is the full read-only detail view and remains able to open a completed task directly. The list response is a single structured `{ok, count, jobs}` envelope while each job record preserves its existing fields. Completed rows remain in the registry and remain part of `scheduler_status` counts. No task-state migration or deployment configuration change is required.