cortexshift 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. cortexshift/__init__.py +10 -0
  2. cortexshift/__main__.py +6 -0
  3. cortexshift/adapters/__init__.py +22 -0
  4. cortexshift/adapters/command_runner.py +116 -0
  5. cortexshift/adapters/discovery.py +55 -0
  6. cortexshift/adapters/git/__init__.py +10 -0
  7. cortexshift/adapters/git/inspector.py +321 -0
  8. cortexshift/adapters/git/parser.py +140 -0
  9. cortexshift/adapters/headless_runner.py +92 -0
  10. cortexshift/adapters/process_runner.py +56 -0
  11. cortexshift/adapters/providers/__init__.py +4 -0
  12. cortexshift/adapters/providers/antigravity.py +530 -0
  13. cortexshift/adapters/providers/claude.py +375 -0
  14. cortexshift/adapters/providers/codex.py +434 -0
  15. cortexshift/adapters/sqlite/__init__.py +10 -0
  16. cortexshift/adapters/sqlite/migrations.py +268 -0
  17. cortexshift/adapters/sqlite/store.py +914 -0
  18. cortexshift/adapters/workspace_lease.py +123 -0
  19. cortexshift/application/__init__.py +42 -0
  20. cortexshift/application/checkpoint_builder.py +218 -0
  21. cortexshift/application/checkpoint_service.py +273 -0
  22. cortexshift/application/doctor.py +80 -0
  23. cortexshift/application/handoff_builder.py +281 -0
  24. cortexshift/application/handoff_renderer.py +430 -0
  25. cortexshift/application/handoff_service.py +66 -0
  26. cortexshift/application/init_service.py +86 -0
  27. cortexshift/application/locator.py +48 -0
  28. cortexshift/application/native_session.py +65 -0
  29. cortexshift/application/recovery_service.py +235 -0
  30. cortexshift/application/repository_service.py +146 -0
  31. cortexshift/application/resume_service.py +124 -0
  32. cortexshift/application/run_service.py +270 -0
  33. cortexshift/application/session_launcher.py +183 -0
  34. cortexshift/application/session_service.py +63 -0
  35. cortexshift/application/source_session.py +62 -0
  36. cortexshift/application/status_service.py +73 -0
  37. cortexshift/application/switch_service.py +671 -0
  38. cortexshift/application/task_service.py +201 -0
  39. cortexshift/application/task_workspace.py +152 -0
  40. cortexshift/cli/__init__.py +5 -0
  41. cortexshift/cli/app.py +2477 -0
  42. cortexshift/domain/__init__.py +153 -0
  43. cortexshift/domain/checkpoint.py +174 -0
  44. cortexshift/domain/doctor.py +68 -0
  45. cortexshift/domain/errors.py +277 -0
  46. cortexshift/domain/git.py +102 -0
  47. cortexshift/domain/handoff.py +241 -0
  48. cortexshift/domain/identifiers.py +27 -0
  49. cortexshift/domain/launch.py +58 -0
  50. cortexshift/domain/mcp_binding.py +81 -0
  51. cortexshift/domain/native_session.py +19 -0
  52. cortexshift/domain/project.py +37 -0
  53. cortexshift/domain/provider.py +67 -0
  54. cortexshift/domain/session.py +92 -0
  55. cortexshift/domain/status.py +40 -0
  56. cortexshift/domain/task.py +191 -0
  57. cortexshift/mcp/__init__.py +38 -0
  58. cortexshift/mcp/context.py +165 -0
  59. cortexshift/mcp/facade.py +513 -0
  60. cortexshift/mcp/models.py +178 -0
  61. cortexshift/mcp/resources.py +45 -0
  62. cortexshift/mcp/server.py +52 -0
  63. cortexshift/mcp/tools.py +176 -0
  64. cortexshift/ports/__init__.py +39 -0
  65. cortexshift/ports/checkpoint_store.py +45 -0
  66. cortexshift/ports/command_runner.py +56 -0
  67. cortexshift/ports/discovery.py +41 -0
  68. cortexshift/ports/handoff_delivery.py +91 -0
  69. cortexshift/ports/handoff_store.py +43 -0
  70. cortexshift/ports/headless_runner.py +58 -0
  71. cortexshift/ports/native_session.py +20 -0
  72. cortexshift/ports/process_runner.py +31 -0
  73. cortexshift/ports/provider.py +152 -0
  74. cortexshift/ports/repository.py +44 -0
  75. cortexshift/ports/session_store.py +27 -0
  76. cortexshift/ports/state_store.py +55 -0
  77. cortexshift/ports/workspace_lease.py +39 -0
  78. cortexshift/tui/__init__.py +24 -0
  79. cortexshift/tui/actions.py +58 -0
  80. cortexshift/tui/app.py +1051 -0
  81. cortexshift/tui/coordinator.py +173 -0
  82. cortexshift/tui/cortexshift.tcss +258 -0
  83. cortexshift/tui/facade.py +614 -0
  84. cortexshift/tui/modals.py +594 -0
  85. cortexshift/tui/models.py +503 -0
  86. cortexshift/tui/screens/__init__.py +81 -0
  87. cortexshift/tui/screens/checkpoints.py +188 -0
  88. cortexshift/tui/screens/handoffs.py +180 -0
  89. cortexshift/tui/screens/help.py +117 -0
  90. cortexshift/tui/screens/overview.py +200 -0
  91. cortexshift/tui/screens/providers.py +169 -0
  92. cortexshift/tui/screens/repository.py +143 -0
  93. cortexshift/tui/screens/sessions.py +146 -0
  94. cortexshift/tui/screens/task.py +174 -0
  95. cortexshift/tui/widgets.py +209 -0
  96. cortexshift-0.1.0.dist-info/METADATA +202 -0
  97. cortexshift-0.1.0.dist-info/RECORD +100 -0
  98. cortexshift-0.1.0.dist-info/WHEEL +4 -0
  99. cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
  100. cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,65 @@
1
+ """Exact resume selection from canonical CortexShift records only."""
2
+
3
+ from cortexshift.domain.errors import (
4
+ NativeResumeError,
5
+ SessionNotFoundError,
6
+ SessionTaskMismatchError,
7
+ )
8
+ from cortexshift.domain.native_session import NativeSessionCapabilities, valid_native_id
9
+ from cortexshift.domain.provider import ProviderId
10
+ from cortexshift.domain.session import Session, SessionExitReason, SessionStatus
11
+ from cortexshift.ports.session_store import SessionStore
12
+
13
+
14
+ def is_native_resumable(session: Session, capabilities: NativeSessionCapabilities) -> bool:
15
+ """Require a known ID and evidence that the provider process actually launched.
16
+
17
+ An allocated ID on an unfinished or spawn-failed invocation is not enough.
18
+ Exit evidence remains valid even when a launched process crashed or was interrupted.
19
+ """
20
+ return (
21
+ capabilities.supports_exact_resume
22
+ and valid_native_id(session.native_session_id)
23
+ and session.status
24
+ in (SessionStatus.COMPLETED, SessionStatus.INTERRUPTED, SessionStatus.FAILED)
25
+ and session.exit_code is not None
26
+ and session.exit_reason != SessionExitReason.SPAWN_FAILED
27
+ )
28
+
29
+
30
+ def select_native_session(
31
+ store: SessionStore,
32
+ task_id: str,
33
+ provider_id: ProviderId,
34
+ capabilities: NativeSessionCapabilities,
35
+ explicit_session_id: str | None = None,
36
+ ) -> Session | None:
37
+ if explicit_session_id is not None:
38
+ session = store.get_session(explicit_session_id)
39
+ if session is None:
40
+ raise SessionNotFoundError(explicit_session_id)
41
+ if session.task_id != task_id:
42
+ raise SessionTaskMismatchError(explicit_session_id, task_id)
43
+ if session.provider_id != provider_id:
44
+ raise NativeResumeError("The selected CortexShift Session belongs to another provider.")
45
+ if not is_native_resumable(session, capabilities):
46
+ raise NativeResumeError("The selected CortexShift Session is not exactly resumable.")
47
+ return session
48
+
49
+ return next(
50
+ (
51
+ session
52
+ for session in store.list_sessions(task_id=task_id, limit=None)
53
+ if session.provider_id == provider_id and is_native_resumable(session, capabilities)
54
+ ),
55
+ None,
56
+ )
57
+
58
+
59
+ def native_capabilities(adapter: object) -> NativeSessionCapabilities:
60
+ """Unsupported adapters honestly advertise no native continuity."""
61
+ from cortexshift.ports.native_session import ProviderNativeSessionAdapter
62
+
63
+ if isinstance(adapter, ProviderNativeSessionAdapter):
64
+ return adapter.get_native_capabilities()
65
+ return NativeSessionCapabilities()
@@ -0,0 +1,235 @@
1
+ """Application service for crash recovery and stale session reconciliation."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from cortexshift.adapters.git.inspector import GitRepositoryInspector
10
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
11
+ from cortexshift.adapters.workspace_lease import FileWorkspaceLeaseManager
12
+ from cortexshift.application.checkpoint_builder import CheckpointBuilder
13
+ from cortexshift.application.handoff_builder import derive_files_touched
14
+ from cortexshift.application.locator import ProjectLocator
15
+ from cortexshift.domain.checkpoint import CheckpointKind
16
+ from cortexshift.domain.errors import (
17
+ GitProbeError,
18
+ NoActiveTaskError,
19
+ ProjectNotInitializedError,
20
+ WorkspaceLockedError,
21
+ )
22
+ from cortexshift.domain.git import RepositoryInspectionStatus
23
+ from cortexshift.domain.identifiers import utc_now
24
+ from cortexshift.domain.session import SessionExitReason, SessionStatus
25
+ from cortexshift.ports.repository import RepositoryInspector
26
+ from cortexshift.ports.workspace_lease import WorkspaceLeaseManager
27
+
28
+
29
+ class RecoveryReport(BaseModel):
30
+ """Structured representation of crash recovery results."""
31
+
32
+ model_config = ConfigDict(frozen=True)
33
+
34
+ project_id: str
35
+ task_id: str
36
+ task_title: str
37
+ stale_session_ids: list[str] = Field(default_factory=list)
38
+ reconciled_session_ids: list[str] = Field(default_factory=list)
39
+ checkpoint_id: str | None = None
40
+ git_snapshot_id: str | None = None
41
+ repository_status: RepositoryInspectionStatus
42
+ dirty: bool = False
43
+ files_touched: list[str] = Field(default_factory=list)
44
+ dry_run: bool = False
45
+ created_at: datetime = Field(default_factory=utc_now)
46
+ metadata: dict[str, Any] = Field(default_factory=dict)
47
+
48
+
49
+ class RecoveryService:
50
+ """Coordinates crash recovery, lease acquisition, and session reconciliation."""
51
+
52
+ def __init__(
53
+ self,
54
+ inspector: RepositoryInspector | None = None,
55
+ lease_manager: WorkspaceLeaseManager | None = None,
56
+ ) -> None:
57
+ self._inspector = inspector or GitRepositoryInspector()
58
+ self._lease_manager = lease_manager or FileWorkspaceLeaseManager()
59
+
60
+ def recover(
61
+ self,
62
+ start_dir: Path | str | None = None,
63
+ *,
64
+ dry_run: bool = False,
65
+ ) -> RecoveryReport:
66
+ """Reconcile interrupted orchestration sessions and capture a Recovery Checkpoint.
67
+
68
+ Requires an exclusive workspace lease to verify no active CortexShift provider
69
+ process currently owns the workspace.
70
+ """
71
+ start_path = Path(start_dir) if start_dir is not None else None
72
+ project_root = ProjectLocator.find_project_root(start_path)
73
+ if project_root is None:
74
+ raise ProjectNotInitializedError()
75
+
76
+ db_path = ProjectLocator.get_database_path(project_root)
77
+ store = SQLiteStateStore(db_path, auto_migrate=False)
78
+
79
+ try:
80
+ project = store.get_default_project()
81
+ if project is None:
82
+ raise ProjectNotInitializedError()
83
+
84
+ active_task_id = store.get_active_task_id(project.id)
85
+ if not active_task_id:
86
+ raise NoActiveTaskError()
87
+
88
+ task = store.get_task(active_task_id)
89
+ if task is None:
90
+ raise NoActiveTaskError()
91
+
92
+ lease = self._lease_manager.get_lease(Path(project.repo_path))
93
+
94
+ if dry_run:
95
+ # Probe lease non-destructively
96
+ if not lease.acquire():
97
+ raise WorkspaceLockedError(lock_path=lease.lock_path)
98
+ lease.release()
99
+
100
+ # Find stale sessions
101
+ sessions = store.list_sessions(task_id=task.id, limit=None)
102
+ stale_sessions = [
103
+ s
104
+ for s in sessions
105
+ if s.status in (SessionStatus.INITIALIZING, SessionStatus.RUNNING)
106
+ ]
107
+ stale_ids = [s.id for s in stale_sessions]
108
+
109
+ inspection = self._inspector.inspect(
110
+ project_root=Path(project.repo_path),
111
+ project_id=project.id,
112
+ )
113
+ if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
114
+ raise GitProbeError(
115
+ inspection.diagnostic
116
+ or "Git repository inspection failed during recovery probe."
117
+ )
118
+
119
+ dirty = inspection.snapshot.dirty if inspection.snapshot else False
120
+ files_touched = derive_files_touched(inspection)
121
+
122
+ return RecoveryReport(
123
+ project_id=project.id,
124
+ task_id=task.id,
125
+ task_title=task.title,
126
+ stale_session_ids=stale_ids,
127
+ reconciled_session_ids=stale_ids,
128
+ checkpoint_id=None,
129
+ git_snapshot_id=None,
130
+ repository_status=inspection.status,
131
+ dirty=dirty,
132
+ files_touched=files_touched,
133
+ dry_run=True,
134
+ metadata={
135
+ "would_reconcile": bool(stale_sessions),
136
+ "would_create_checkpoint": bool(stale_sessions),
137
+ },
138
+ )
139
+
140
+ # Non-dry-run: acquire lease
141
+ if not lease.acquire():
142
+ raise WorkspaceLockedError(lock_path=lease.lock_path)
143
+
144
+ try:
145
+ sessions = store.list_sessions(task_id=task.id, limit=None)
146
+ stale_sessions = [
147
+ s
148
+ for s in sessions
149
+ if s.status in (SessionStatus.INITIALIZING, SessionStatus.RUNNING)
150
+ ]
151
+ stale_ids = [s.id for s in stale_sessions]
152
+
153
+ inspection = self._inspector.inspect(
154
+ project_root=Path(project.repo_path),
155
+ project_id=project.id,
156
+ )
157
+ if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
158
+ raise GitProbeError(
159
+ inspection.diagnostic or "Git repository inspection failed during recovery."
160
+ )
161
+
162
+ dirty = inspection.snapshot.dirty if inspection.snapshot else False
163
+ files_touched = derive_files_touched(inspection)
164
+
165
+ if not stale_sessions:
166
+ # Predictable no-op when no stale sessions exist
167
+ return RecoveryReport(
168
+ project_id=project.id,
169
+ task_id=task.id,
170
+ task_title=task.title,
171
+ stale_session_ids=[],
172
+ reconciled_session_ids=[],
173
+ checkpoint_id=None,
174
+ git_snapshot_id=None,
175
+ repository_status=inspection.status,
176
+ dirty=dirty,
177
+ files_touched=files_touched,
178
+ dry_run=False,
179
+ metadata={"message": "No stale sessions found to recover."},
180
+ )
181
+
182
+ # Save Git snapshot if ready
183
+ snapshot_id: str | None = None
184
+ if (
185
+ inspection.status == RepositoryInspectionStatus.READY
186
+ and inspection.snapshot is not None
187
+ ):
188
+ store.save_snapshot(inspection.snapshot)
189
+ snapshot_id = inspection.snapshot.id
190
+
191
+ # Recovery Checkpoint associates with newest stale session
192
+ primary_stale = stale_sessions[0]
193
+ checkpoint = CheckpointBuilder.build(
194
+ project=project,
195
+ task=task,
196
+ inspection=inspection,
197
+ kind=CheckpointKind.RECOVERY,
198
+ snapshot_id=snapshot_id,
199
+ session=primary_stale,
200
+ metadata={"reconciled_session_ids": stale_ids},
201
+ )
202
+ store.save_checkpoint(checkpoint)
203
+
204
+ # Reconcile all stale sessions
205
+ recovery_time = utc_now()
206
+ reconciled_ids: list[str] = []
207
+ for s in stale_sessions:
208
+ reconciled = s.model_copy(
209
+ update={
210
+ "status": SessionStatus.INTERRUPTED,
211
+ "exit_reason": SessionExitReason.UNEXPECTED_TERMINATION,
212
+ "reconciled_at": recovery_time,
213
+ }
214
+ )
215
+ store.save_session(reconciled)
216
+ reconciled_ids.append(reconciled.id)
217
+
218
+ return RecoveryReport(
219
+ project_id=project.id,
220
+ task_id=task.id,
221
+ task_title=task.title,
222
+ stale_session_ids=stale_ids,
223
+ reconciled_session_ids=reconciled_ids,
224
+ checkpoint_id=checkpoint.id,
225
+ git_snapshot_id=snapshot_id,
226
+ repository_status=inspection.status,
227
+ dirty=dirty,
228
+ files_touched=files_touched,
229
+ dry_run=False,
230
+ metadata={"reconciled_count": len(reconciled_ids)},
231
+ )
232
+ finally:
233
+ lease.release()
234
+ finally:
235
+ store.close()
@@ -0,0 +1,146 @@
1
+ """Application service for Git repository inspection and snapshot persistence."""
2
+
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from pathlib import Path
6
+
7
+ from cortexshift.adapters.git.inspector import GitRepositoryInspector
8
+ from cortexshift.adapters.sqlite.store import SQLiteStateStore
9
+ from cortexshift.application.locator import ProjectLocator
10
+ from cortexshift.domain.errors import (
11
+ GitNotInstalledError,
12
+ GitProbeError,
13
+ GitProbeTimeoutError,
14
+ NotAGitRepositoryError,
15
+ ProjectNotInitializedError,
16
+ )
17
+ from cortexshift.domain.git import (
18
+ GitSnapshot,
19
+ RepositoryInspection,
20
+ RepositoryInspectionStatus,
21
+ )
22
+ from cortexshift.domain.project import Project
23
+ from cortexshift.ports.repository import RepositoryInspector, RepositorySnapshotStore
24
+
25
+
26
+ class RepositoryService:
27
+ """Coordinates live Git repository inspection and snapshot persistence.
28
+
29
+ Enforces architecture layering:
30
+ - Never executes raw subprocess commands directly (delegates to RepositoryInspector).
31
+ - Never executes raw SQL statements directly (delegates to RepositorySnapshotStore).
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ inspector: RepositoryInspector | None = None,
37
+ store: RepositorySnapshotStore | None = None,
38
+ locator: type[ProjectLocator] = ProjectLocator,
39
+ ) -> None:
40
+ self._inspector = inspector or GitRepositoryInspector()
41
+ self._store = store
42
+ self._locator = locator
43
+
44
+ @contextmanager
45
+ def _resolve_project_and_store(
46
+ self, start_path: Path | None = None
47
+ ) -> Iterator[tuple[Path, Project, RepositorySnapshotStore]]:
48
+ """Locate the initialized project and yield its snapshot store.
49
+
50
+ A store opened here is closed on exit. An injected store belongs to its caller
51
+ and is left open, so callers that share one connection (the MCP server, for
52
+ instance) keep working.
53
+ """
54
+ project_root = self._locator.find_project_root(start_path)
55
+ if project_root is None:
56
+ raise ProjectNotInitializedError()
57
+
58
+ if self._store is not None:
59
+ store = self._store
60
+ # If store is also a StateStore, fetch default project
61
+ if hasattr(store, "get_default_project"):
62
+ project = store.get_default_project()
63
+ if project is None:
64
+ raise ProjectNotInitializedError()
65
+ else:
66
+ project = Project(
67
+ id="proj_resolved",
68
+ name=project_root.name,
69
+ repo_path=str(project_root),
70
+ )
71
+ yield project_root, project, store
72
+ return
73
+
74
+ db_path = self._locator.get_database_path(project_root)
75
+ sqlite_store = SQLiteStateStore(db_path, auto_migrate=True)
76
+ try:
77
+ project = sqlite_store.get_default_project()
78
+ if project is None:
79
+ raise ProjectNotInitializedError()
80
+ yield project_root, project, sqlite_store
81
+ finally:
82
+ sqlite_store.close()
83
+
84
+ def inspect_repository(self, start_path: Path | None = None) -> RepositoryInspection:
85
+ """Perform a live, non-persisting inspection of the repository."""
86
+ with self._resolve_project_and_store(start_path) as (project_root, project, _):
87
+ return self._inspector.inspect(project_root=project_root, project_id=project.id)
88
+
89
+ def capture_snapshot(self, start_path: Path | None = None) -> GitSnapshot:
90
+ """Perform live inspection and persist the snapshot if successful.
91
+
92
+ Raises:
93
+ GitNotInstalledError: If Git is not found in PATH.
94
+ NotAGitRepositoryError: If the project is not in a Git repository.
95
+ GitProbeTimeoutError: If inspection timed out.
96
+ GitProbeError: If inspection failed.
97
+ """
98
+ with self._resolve_project_and_store(start_path) as (project_root, project, store):
99
+ return self._capture(project_root, project, store)
100
+
101
+ def _capture(
102
+ self,
103
+ project_root: Path,
104
+ project: Project,
105
+ store: RepositorySnapshotStore,
106
+ ) -> GitSnapshot:
107
+ """Inspect the working tree and persist the resulting snapshot."""
108
+ inspection = self._inspector.inspect(project_root=project_root, project_id=project.id)
109
+
110
+ if inspection.status == RepositoryInspectionStatus.GIT_NOT_INSTALLED:
111
+ raise GitNotInstalledError(
112
+ inspection.diagnostic or "Git executable was not found in PATH."
113
+ )
114
+
115
+ if inspection.status == RepositoryInspectionStatus.NOT_GIT_REPOSITORY:
116
+ raise NotAGitRepositoryError(
117
+ inspection.diagnostic or "This CortexShift project is not inside a Git repository."
118
+ )
119
+
120
+ if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
121
+ diag = inspection.diagnostic or "Git repository inspection failed."
122
+ if "timed out" in diag.lower():
123
+ raise GitProbeTimeoutError(diag)
124
+ raise GitProbeError(diag)
125
+
126
+ if inspection.snapshot is None:
127
+ raise GitProbeError("No repository snapshot was generated.")
128
+
129
+ store.save_snapshot(inspection.snapshot)
130
+ return inspection.snapshot
131
+
132
+ def list_snapshots(
133
+ self,
134
+ project_id: str | None = None,
135
+ limit: int = 10,
136
+ start_path: Path | None = None,
137
+ ) -> list[GitSnapshot]:
138
+ """List historical snapshots for a project, newest first."""
139
+ with self._resolve_project_and_store(start_path) as (_, project, store):
140
+ pid = project_id or project.id
141
+ return store.list_snapshots(project_id=pid, limit=limit)
142
+
143
+ def get_snapshot(self, snapshot_id: str, start_path: Path | None = None) -> GitSnapshot | None:
144
+ """Retrieve a stored snapshot by ID."""
145
+ with self._resolve_project_and_store(start_path) as (_, _, store):
146
+ return store.get_snapshot(snapshot_id)
@@ -0,0 +1,124 @@
1
+ """Exact native resume without creating a handoff or repository snapshot."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from cortexshift.adapters.providers.antigravity import (
7
+ ANTIGRAVITY_MCP_MISSING_NOTICE,
8
+ is_antigravity_mcp_configured,
9
+ )
10
+ from cortexshift.application.native_session import select_native_session
11
+ from cortexshift.application.run_service import DryRunResult, RunService
12
+ from cortexshift.application.session_launcher import ProviderSessionLauncher
13
+ from cortexshift.domain.errors import NativeResumeError, TerminalRequiredError, WorkspaceLockedError
14
+ from cortexshift.domain.provider import PROVIDER_ANTIGRAVITY
15
+ from cortexshift.domain.session import Session
16
+ from cortexshift.ports.native_session import ProviderNativeSessionAdapter
17
+ from cortexshift.ports.provider import ManagedMcpBinder
18
+
19
+
20
+ class ResumeDryRunResult(DryRunResult):
21
+ source_session_id: str
22
+ native_session_id: str
23
+ native_session_known: bool = True
24
+ resume_strategy: str = "exact_native_id"
25
+ bootstrap_model_turn_required: bool = False
26
+
27
+ def to_dict(self) -> dict[str, object]:
28
+ return self.model_dump(mode="json")
29
+
30
+
31
+ class ResumeService(RunService):
32
+ """Share run context resolution and injected execution boundaries; never nest leases."""
33
+
34
+ def resume(
35
+ self,
36
+ provider_name: str,
37
+ session_id: str | None = None,
38
+ start_dir: Path | str | None = None,
39
+ *,
40
+ dry_run: bool = False,
41
+ ) -> Session | ResumeDryRunResult:
42
+ _, project, task, adapter, executable, store = self._resolve_context(
43
+ provider_name, start_dir
44
+ )
45
+ try:
46
+ if not isinstance(adapter, ProviderNativeSessionAdapter):
47
+ raise NativeResumeError("This provider does not support exact native resume.")
48
+
49
+ def select() -> Session:
50
+ source = select_native_session(
51
+ store,
52
+ task.id,
53
+ adapter.provider_id,
54
+ adapter.get_native_capabilities(),
55
+ session_id,
56
+ )
57
+ if source is None:
58
+ raise NativeResumeError(
59
+ f"No exact resumable {adapter.display_name} native session is recorded "
60
+ "for the active task. CortexShift will not guess a provider session. "
61
+ "Start a new native session with run, or switch to this provider instead."
62
+ )
63
+ return source
64
+
65
+ source = select()
66
+ assert source.native_session_id is not None
67
+ spec = adapter.build_exact_resume(
68
+ Path(project.repo_path), executable, source.native_session_id
69
+ )
70
+ if dry_run:
71
+ return ResumeDryRunResult(
72
+ provider_id=adapter.provider_id,
73
+ display_name=adapter.display_name,
74
+ executable=executable,
75
+ project_id=project.id,
76
+ project_name=project.name,
77
+ task_id=task.id,
78
+ task_title=task.title,
79
+ cwd=spec.cwd,
80
+ argv=spec.to_redacted_argv(),
81
+ mode="native_resume",
82
+ source_session_id=source.id,
83
+ native_session_id=source.native_session_id,
84
+ )
85
+ if not self._is_tty():
86
+ raise TerminalRequiredError(
87
+ "Interactive resume requires a terminal (TTY). "
88
+ f"Use cortexshift resume {provider_name} --dry-run to preview."
89
+ )
90
+ lease = self._lease_manager.get_lease(Path(project.repo_path))
91
+ if not lease.acquire():
92
+ raise WorkspaceLockedError(lock_path=lease.lock_path)
93
+ try:
94
+ # Select again under the lease in case another invocation just finished.
95
+ source = select()
96
+ assert source.native_session_id is not None
97
+ spec = adapter.build_exact_resume(
98
+ Path(project.repo_path), executable, source.native_session_id
99
+ )
100
+ launcher = ProviderSessionLauncher(
101
+ self._runner,
102
+ store,
103
+ checkpoint_service=self._checkpoint_service,
104
+ )
105
+ invocation = launcher.start_session(
106
+ task.id,
107
+ adapter.provider_id,
108
+ native_session_id=source.native_session_id,
109
+ resumed_from_session_id=source.id,
110
+ )
111
+ if str(adapter.provider_id) == str(
112
+ PROVIDER_ANTIGRAVITY
113
+ ) and not is_antigravity_mcp_configured(Path(project.repo_path)):
114
+ sys.stderr.write(f"\n{ANTIGRAVITY_MCP_MISSING_NOTICE}\n\n")
115
+
116
+ return launcher.run(
117
+ invocation,
118
+ spec,
119
+ mcp_binder=adapter if isinstance(adapter, ManagedMcpBinder) else None,
120
+ )
121
+ finally:
122
+ lease.release()
123
+ finally:
124
+ store.close()