feat(mcp): add verified ingest job phases
Expose authoritative ingest phases and supervisor-backed process liveness without semantic activity inference. Require deterministic registry, manifest, archive, file-index, and declared-output verification before an agent-successful job can finish succeeded.\n\nPreserve legacy state values, derive defaults for older job records, keep verification outside the manager lock, and retain partial outputs on verification failure.\n\nValidated with the full unittest suite (205 tests).
This commit is contained in:
+324
-3
@@ -4,6 +4,7 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
@@ -423,12 +424,313 @@ class McpIngestTests(unittest.TestCase):
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(self._json_text_event("MCP_INGEST_OK\nIngested source.pdf"), encoding="utf-8")
|
||||
popen.returncode = 0
|
||||
self.manager.refresh_jobs()
|
||||
with patch(
|
||||
"obsidian_wiki.mcp_ingest.verify_ingest_outputs",
|
||||
return_value=self._passed_verification(1),
|
||||
):
|
||||
self.manager.refresh_jobs()
|
||||
status = self.manager.get_job_status(job["job_id"])
|
||||
self.assertEqual(status["state"], "succeeded")
|
||||
self.assertEqual(status["exit_code"], 0)
|
||||
self.assertNotIn("path", status["files"][0])
|
||||
|
||||
def test_job_reports_authoritative_preflight_and_agent_running_phases(self):
|
||||
upload = self._complete_upload("source.pdf", b"source data")
|
||||
popen = FakePopen(returncode=None)
|
||||
observed = {}
|
||||
|
||||
def readiness_during_launch(*, run_smoke_test=False):
|
||||
internal = next(iter(self.manager._iter_jobs()))
|
||||
observed["state"] = internal["state"]
|
||||
observed["phase"] = internal["phase"]
|
||||
observed["verification"] = internal["verification"]
|
||||
lock_probe = []
|
||||
|
||||
def acquire_manager_lock():
|
||||
acquired = self.manager._lock.acquire(timeout=0.2)
|
||||
lock_probe.append(acquired)
|
||||
if acquired:
|
||||
self.manager._lock.release()
|
||||
|
||||
thread = threading.Thread(target=acquire_manager_lock)
|
||||
thread.start()
|
||||
thread.join()
|
||||
observed["status_lock_available"] = lock_probe == [True]
|
||||
return {
|
||||
"ok": True,
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"static_preflight": True,
|
||||
"runtime_smoke_test": "skipped",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(self.manager, "check_opencode_readiness", side_effect=readiness_during_launch),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
status = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
self.assertEqual(observed["state"], "running")
|
||||
self.assertEqual(observed["phase"], "preflight")
|
||||
self.assertEqual(observed["verification"]["status"], "not_run")
|
||||
self.assertTrue(observed["status_lock_available"])
|
||||
self.assertEqual(status["state"], "running")
|
||||
self.assertEqual(status["phase"], "agent_running")
|
||||
self.assertTrue(status["phase_started_at"])
|
||||
self.assertTrue(status["process_alive"])
|
||||
self.assertEqual(status["verification"]["status"], "not_run")
|
||||
|
||||
def test_queued_and_cancelled_job_phases_are_authoritative(self):
|
||||
first = self._complete_upload("first.pdf", b"first")
|
||||
second = self._complete_upload("second.pdf", b"second")
|
||||
popen = FakePopen(returncode=None)
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
self.manager.start_ingest_job([first["upload_id"]])
|
||||
queued = self.manager.start_ingest_job([second["upload_id"]])
|
||||
|
||||
self.assertEqual(queued["state"], "queued")
|
||||
self.assertEqual(queued["phase"], "queued")
|
||||
self.assertFalse(queued["process_alive"])
|
||||
self.assertEqual(queued["verification"]["status"], "not_run")
|
||||
|
||||
cancelled = self.manager.cancel_ingest_job(queued["job_id"])
|
||||
self.assertEqual(cancelled["state"], "cancelled")
|
||||
self.assertEqual(cancelled["phase"], "complete")
|
||||
self.assertFalse(cancelled["process_alive"])
|
||||
self.assertEqual(cancelled["verification"]["status"], "not_run")
|
||||
|
||||
def test_preflight_failure_completes_without_verification(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
(self.project / ".agents" / "skills" / "wiki-ingest" / "SKILL.md").unlink()
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen") as popen_mock,
|
||||
):
|
||||
status = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
self.assertEqual(status["state"], "failed")
|
||||
self.assertEqual(status["phase"], "complete")
|
||||
self.assertFalse(status["process_alive"])
|
||||
self.assertEqual(status["verification"]["status"], "not_run")
|
||||
popen_mock.assert_not_called()
|
||||
|
||||
def test_preflight_job_can_be_cancelled_while_readiness_runs(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
cancelled = {}
|
||||
|
||||
def cancel_during_readiness(*, run_smoke_test=False):
|
||||
internal = next(iter(self.manager._iter_jobs()))
|
||||
|
||||
def cancel_job():
|
||||
cancelled.update(self.manager.cancel_ingest_job(internal["job_id"]))
|
||||
|
||||
thread = threading.Thread(target=cancel_job)
|
||||
thread.start()
|
||||
thread.join()
|
||||
return {
|
||||
"ok": True,
|
||||
"errors": [],
|
||||
"warnings": [],
|
||||
"static_preflight": True,
|
||||
"runtime_smoke_test": "skipped",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(self.manager, "check_opencode_readiness", side_effect=cancel_during_readiness),
|
||||
patch(
|
||||
"obsidian_wiki.mcp_ingest.subprocess.Popen",
|
||||
return_value=FakePopen(returncode=None),
|
||||
) as popen_mock,
|
||||
):
|
||||
status = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
self.assertEqual(cancelled["state"], "cancelled")
|
||||
self.assertEqual(status["state"], "cancelled")
|
||||
self.assertEqual(status["phase"], "complete")
|
||||
self.assertEqual(status["verification"]["status"], "not_run")
|
||||
popen_mock.assert_not_called()
|
||||
|
||||
def test_unexpected_preflight_error_finishes_job(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
with (
|
||||
patch.object(
|
||||
self.manager,
|
||||
"check_opencode_readiness",
|
||||
side_effect=RuntimeError("readiness crashed"),
|
||||
),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen") as popen_mock,
|
||||
):
|
||||
status = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
self.assertEqual(status["state"], "failed")
|
||||
self.assertEqual(status["phase"], "complete")
|
||||
self.assertEqual(status["verification"]["status"], "not_run")
|
||||
self.assertIn("readiness crashed", status["error"])
|
||||
popen_mock.assert_not_called()
|
||||
|
||||
def test_legacy_job_records_derive_phase_and_verification_defaults(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
(self.project / ".agents" / "skills" / "wiki-ingest" / "SKILL.md").unlink()
|
||||
with patch(
|
||||
"obsidian_wiki.mcp_ingest.subprocess.run",
|
||||
side_effect=self._fake_static_readiness_run,
|
||||
):
|
||||
status = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
internal = self.manager._load_job(status["job_id"])
|
||||
internal.pop("phase", None)
|
||||
internal.pop("phase_started_at", None)
|
||||
internal.pop("verification", None)
|
||||
self.manager._write_job(internal)
|
||||
|
||||
legacy = self.manager.get_ingest_job_status(status["job_id"])
|
||||
self.assertEqual(legacy["phase"], "complete")
|
||||
self.assertEqual(legacy["verification"]["status"], "not_run")
|
||||
self.assertFalse(legacy["process_alive"])
|
||||
|
||||
def test_service_restart_completes_interrupted_job_without_verification(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
(self.project / ".agents" / "skills" / "wiki-ingest" / "SKILL.md").unlink()
|
||||
with patch(
|
||||
"obsidian_wiki.mcp_ingest.subprocess.run",
|
||||
side_effect=self._fake_static_readiness_run,
|
||||
):
|
||||
status = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
|
||||
internal = self.manager._load_job(status["job_id"])
|
||||
internal["state"] = "running"
|
||||
internal["phase"] = "verifying"
|
||||
internal["verification"] = {"status": "running"}
|
||||
internal["finished_at"] = None
|
||||
self.manager._write_job(internal)
|
||||
self.manager._mark_interrupted_jobs()
|
||||
|
||||
restarted = self.manager.get_ingest_job_status(status["job_id"])
|
||||
self.assertEqual(restarted["state"], "failed")
|
||||
self.assertEqual(restarted["phase"], "complete")
|
||||
self.assertEqual(restarted["verification"]["status"], "not_run")
|
||||
self.assertIn("service restart", restarted["error"])
|
||||
|
||||
def test_success_marker_runs_verification_before_succeeding(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
popen = FakePopen(returncode=None)
|
||||
observed = {}
|
||||
passed = {
|
||||
"status": "passed",
|
||||
"checked_at": "2026-07-16T10:01:00Z",
|
||||
"failures": [],
|
||||
"warnings": [],
|
||||
"outputs": {
|
||||
"source_count": 1,
|
||||
"registry_count": 1,
|
||||
"archive_count": 1,
|
||||
"manifest_count": 1,
|
||||
"indexed_count": 1,
|
||||
},
|
||||
}
|
||||
|
||||
def verify_while_observable(**kwargs):
|
||||
internal = self.manager._load_job(job["job_id"])
|
||||
public = self.manager._public_job(internal)
|
||||
observed["phase"] = public["phase"]
|
||||
observed["process_alive"] = public["process_alive"]
|
||||
observed["verification"] = public["verification"]
|
||||
observed["uploads"] = kwargs["uploads"]
|
||||
return passed
|
||||
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
self._json_text_event("MCP_INGEST_OK\nIngested source.pdf"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
with patch(
|
||||
"obsidian_wiki.mcp_ingest.verify_ingest_outputs",
|
||||
side_effect=verify_while_observable,
|
||||
):
|
||||
self.manager.refresh_jobs()
|
||||
|
||||
status = self.manager.get_ingest_job_status(job["job_id"])
|
||||
self.assertEqual(observed["phase"], "verifying")
|
||||
self.assertFalse(observed["process_alive"])
|
||||
self.assertEqual(observed["verification"]["status"], "running")
|
||||
self.assertEqual(observed["uploads"][0]["path"], str(self._upload_payload_path(upload["upload_id"])))
|
||||
self.assertEqual(status["state"], "succeeded")
|
||||
self.assertEqual(status["phase"], "complete")
|
||||
self.assertFalse(status["process_alive"])
|
||||
self.assertEqual(status["verification"], passed)
|
||||
|
||||
def test_failed_verification_overrides_agent_success_and_preserves_output(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
popen = FakePopen(returncode=None)
|
||||
failed = {
|
||||
"status": "failed",
|
||||
"checked_at": "2026-07-16T10:01:00Z",
|
||||
"failures": [{"code": "manifest_missing", "upload_id": upload["upload_id"]}],
|
||||
"warnings": [],
|
||||
"outputs": {
|
||||
"source_count": 1,
|
||||
"registry_count": 1,
|
||||
"archive_count": 1,
|
||||
"manifest_count": 0,
|
||||
"indexed_count": 1,
|
||||
},
|
||||
}
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
self._json_text_event("MCP_INGEST_OK\nCreated references/source.md"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
with patch(
|
||||
"obsidian_wiki.mcp_ingest.verify_ingest_outputs",
|
||||
return_value=failed,
|
||||
):
|
||||
self.manager.refresh_jobs()
|
||||
|
||||
result = self.manager.get_ingest_job_result(job["job_id"])
|
||||
self.assertEqual(result["state"], "failed")
|
||||
self.assertEqual(result["phase"], "complete")
|
||||
self.assertEqual(result["verification"], failed)
|
||||
self.assertIn("verification failed", result["error"])
|
||||
self.assertIn("Created references/source.md", result["opencode_output_tail"])
|
||||
|
||||
def test_protocol_failure_skips_verification(self):
|
||||
upload = self._complete_upload("source.pdf", b"source")
|
||||
popen = FakePopen(returncode=None)
|
||||
with (
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.run", side_effect=self._fake_static_readiness_run),
|
||||
patch("obsidian_wiki.mcp_ingest.subprocess.Popen", return_value=popen),
|
||||
):
|
||||
job = self.manager.start_ingest_job([upload["upload_id"]])
|
||||
log_path = Path(self.manager._load_job(job["job_id"])["log_path"])
|
||||
log_path.write_text(
|
||||
self._json_text_event("MCP_INGEST_FAILED\nagent failed"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
with patch("obsidian_wiki.mcp_ingest.verify_ingest_outputs") as verify_mock:
|
||||
self.manager.refresh_jobs()
|
||||
|
||||
status = self.manager.get_ingest_job_status(job["job_id"])
|
||||
self.assertEqual(status["state"], "failed")
|
||||
self.assertEqual(status["phase"], "complete")
|
||||
self.assertEqual(status["verification"]["status"], "not_run")
|
||||
verify_mock.assert_not_called()
|
||||
|
||||
def test_ingest_result_rejects_skill_failure_with_zero_exit_code(self):
|
||||
upload = self._complete_upload("source.pdf", b"source data")
|
||||
popen = FakePopen(returncode=None)
|
||||
@@ -464,8 +766,12 @@ class McpIngestTests(unittest.TestCase):
|
||||
encoding="utf-8",
|
||||
)
|
||||
popen.returncode = 0
|
||||
result = self.manager.get_ingest_job_result(job["job_id"])
|
||||
status = self.manager.get_ingest_job_status(job["job_id"])
|
||||
with patch(
|
||||
"obsidian_wiki.mcp_ingest.verify_ingest_outputs",
|
||||
return_value=self._passed_verification(1),
|
||||
):
|
||||
result = self.manager.get_ingest_job_result(job["job_id"])
|
||||
status = self.manager.get_ingest_job_status(job["job_id"])
|
||||
|
||||
self.assertEqual(result["state"], "succeeded")
|
||||
self.assertEqual(result["opencode_output_tail"], "Ingest complete")
|
||||
@@ -679,6 +985,21 @@ class McpIngestTests(unittest.TestCase):
|
||||
raise subprocess.TimeoutExpired(args, kwargs.get("timeout"))
|
||||
raise AssertionError(f"unexpected subprocess.run args: {args}")
|
||||
|
||||
def _passed_verification(self, source_count):
|
||||
return {
|
||||
"status": "passed",
|
||||
"checked_at": "2026-07-16T10:01:00Z",
|
||||
"failures": [],
|
||||
"warnings": [],
|
||||
"outputs": {
|
||||
"source_count": source_count,
|
||||
"registry_count": source_count,
|
||||
"archive_count": source_count,
|
||||
"manifest_count": source_count,
|
||||
"indexed_count": source_count,
|
||||
},
|
||||
}
|
||||
|
||||
def _json_text_event(self, text):
|
||||
return json.dumps(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user