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.
- cortexshift/__init__.py +10 -0
- cortexshift/__main__.py +6 -0
- cortexshift/adapters/__init__.py +22 -0
- cortexshift/adapters/command_runner.py +116 -0
- cortexshift/adapters/discovery.py +55 -0
- cortexshift/adapters/git/__init__.py +10 -0
- cortexshift/adapters/git/inspector.py +321 -0
- cortexshift/adapters/git/parser.py +140 -0
- cortexshift/adapters/headless_runner.py +92 -0
- cortexshift/adapters/process_runner.py +56 -0
- cortexshift/adapters/providers/__init__.py +4 -0
- cortexshift/adapters/providers/antigravity.py +530 -0
- cortexshift/adapters/providers/claude.py +375 -0
- cortexshift/adapters/providers/codex.py +434 -0
- cortexshift/adapters/sqlite/__init__.py +10 -0
- cortexshift/adapters/sqlite/migrations.py +268 -0
- cortexshift/adapters/sqlite/store.py +914 -0
- cortexshift/adapters/workspace_lease.py +123 -0
- cortexshift/application/__init__.py +42 -0
- cortexshift/application/checkpoint_builder.py +218 -0
- cortexshift/application/checkpoint_service.py +273 -0
- cortexshift/application/doctor.py +80 -0
- cortexshift/application/handoff_builder.py +281 -0
- cortexshift/application/handoff_renderer.py +430 -0
- cortexshift/application/handoff_service.py +66 -0
- cortexshift/application/init_service.py +86 -0
- cortexshift/application/locator.py +48 -0
- cortexshift/application/native_session.py +65 -0
- cortexshift/application/recovery_service.py +235 -0
- cortexshift/application/repository_service.py +146 -0
- cortexshift/application/resume_service.py +124 -0
- cortexshift/application/run_service.py +270 -0
- cortexshift/application/session_launcher.py +183 -0
- cortexshift/application/session_service.py +63 -0
- cortexshift/application/source_session.py +62 -0
- cortexshift/application/status_service.py +73 -0
- cortexshift/application/switch_service.py +671 -0
- cortexshift/application/task_service.py +201 -0
- cortexshift/application/task_workspace.py +152 -0
- cortexshift/cli/__init__.py +5 -0
- cortexshift/cli/app.py +2477 -0
- cortexshift/domain/__init__.py +153 -0
- cortexshift/domain/checkpoint.py +174 -0
- cortexshift/domain/doctor.py +68 -0
- cortexshift/domain/errors.py +277 -0
- cortexshift/domain/git.py +102 -0
- cortexshift/domain/handoff.py +241 -0
- cortexshift/domain/identifiers.py +27 -0
- cortexshift/domain/launch.py +58 -0
- cortexshift/domain/mcp_binding.py +81 -0
- cortexshift/domain/native_session.py +19 -0
- cortexshift/domain/project.py +37 -0
- cortexshift/domain/provider.py +67 -0
- cortexshift/domain/session.py +92 -0
- cortexshift/domain/status.py +40 -0
- cortexshift/domain/task.py +191 -0
- cortexshift/mcp/__init__.py +38 -0
- cortexshift/mcp/context.py +165 -0
- cortexshift/mcp/facade.py +513 -0
- cortexshift/mcp/models.py +178 -0
- cortexshift/mcp/resources.py +45 -0
- cortexshift/mcp/server.py +52 -0
- cortexshift/mcp/tools.py +176 -0
- cortexshift/ports/__init__.py +39 -0
- cortexshift/ports/checkpoint_store.py +45 -0
- cortexshift/ports/command_runner.py +56 -0
- cortexshift/ports/discovery.py +41 -0
- cortexshift/ports/handoff_delivery.py +91 -0
- cortexshift/ports/handoff_store.py +43 -0
- cortexshift/ports/headless_runner.py +58 -0
- cortexshift/ports/native_session.py +20 -0
- cortexshift/ports/process_runner.py +31 -0
- cortexshift/ports/provider.py +152 -0
- cortexshift/ports/repository.py +44 -0
- cortexshift/ports/session_store.py +27 -0
- cortexshift/ports/state_store.py +55 -0
- cortexshift/ports/workspace_lease.py +39 -0
- cortexshift/tui/__init__.py +24 -0
- cortexshift/tui/actions.py +58 -0
- cortexshift/tui/app.py +1051 -0
- cortexshift/tui/coordinator.py +173 -0
- cortexshift/tui/cortexshift.tcss +258 -0
- cortexshift/tui/facade.py +614 -0
- cortexshift/tui/modals.py +594 -0
- cortexshift/tui/models.py +503 -0
- cortexshift/tui/screens/__init__.py +81 -0
- cortexshift/tui/screens/checkpoints.py +188 -0
- cortexshift/tui/screens/handoffs.py +180 -0
- cortexshift/tui/screens/help.py +117 -0
- cortexshift/tui/screens/overview.py +200 -0
- cortexshift/tui/screens/providers.py +169 -0
- cortexshift/tui/screens/repository.py +143 -0
- cortexshift/tui/screens/sessions.py +146 -0
- cortexshift/tui/screens/task.py +174 -0
- cortexshift/tui/widgets.py +209 -0
- cortexshift-0.1.0.dist-info/METADATA +202 -0
- cortexshift-0.1.0.dist-info/RECORD +100 -0
- cortexshift-0.1.0.dist-info/WHEEL +4 -0
- cortexshift-0.1.0.dist-info/entry_points.txt +2 -0
- cortexshift-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
"""Application orchestration for manual agent switching and canonical handoff delivery.
|
|
2
|
+
|
|
3
|
+
`SwitchService` is the service behind CortexShift's central product promise: one Task,
|
|
4
|
+
multiple coding agents, no need to manually re-explain the work.
|
|
5
|
+
|
|
6
|
+
Critically, the outgoing agent is never required. The handoff is derived deterministically
|
|
7
|
+
from durable local state — canonical Project, canonical Task, previous CortexShift Session
|
|
8
|
+
metadata, live repository inspection, and the Git snapshot captured at switch time — so
|
|
9
|
+
switching still works after the outgoing provider's quota is exhausted, its process is
|
|
10
|
+
gone, or its CLI is no longer even installed. No outgoing model call is ever made.
|
|
11
|
+
|
|
12
|
+
The service renders no Rich output, knows nothing about Typer, contains no SQL, and
|
|
13
|
+
constructs no provider-specific commands; provider variance lives behind handoff
|
|
14
|
+
delivery adapters.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict
|
|
25
|
+
|
|
26
|
+
from cortexshift.adapters.git.inspector import GitRepositoryInspector
|
|
27
|
+
from cortexshift.adapters.process_runner import SubprocessInteractiveProcessRunner
|
|
28
|
+
from cortexshift.adapters.providers.antigravity import (
|
|
29
|
+
ANTIGRAVITY_MCP_MISSING_NOTICE,
|
|
30
|
+
AntigravityHandoffAdapter,
|
|
31
|
+
is_antigravity_mcp_configured,
|
|
32
|
+
)
|
|
33
|
+
from cortexshift.adapters.providers.claude import ClaudeHandoffAdapter
|
|
34
|
+
from cortexshift.adapters.providers.codex import CodexHandoffAdapter
|
|
35
|
+
from cortexshift.adapters.sqlite.store import SQLiteStateStore
|
|
36
|
+
from cortexshift.adapters.workspace_lease import FileWorkspaceLeaseManager
|
|
37
|
+
from cortexshift.application.checkpoint_service import CheckpointService
|
|
38
|
+
from cortexshift.application.handoff_builder import HandoffBuilder, aggregate_task_decisions
|
|
39
|
+
from cortexshift.application.handoff_renderer import HandoffRenderer, RenderedHandoffContext
|
|
40
|
+
from cortexshift.application.locator import ProjectLocator
|
|
41
|
+
from cortexshift.application.native_session import native_capabilities, select_native_session
|
|
42
|
+
from cortexshift.application.run_service import ProviderRuntimeRegistry
|
|
43
|
+
from cortexshift.application.session_launcher import ProviderSessionLauncher
|
|
44
|
+
from cortexshift.application.source_session import select_source_session
|
|
45
|
+
from cortexshift.domain.errors import (
|
|
46
|
+
GitProbeError,
|
|
47
|
+
HandoffDeliveryError,
|
|
48
|
+
NativeResumeError,
|
|
49
|
+
NoActiveTaskError,
|
|
50
|
+
ProjectNotInitializedError,
|
|
51
|
+
ProviderNotFoundError,
|
|
52
|
+
SameProviderSwitchError,
|
|
53
|
+
TerminalRequiredError,
|
|
54
|
+
UnknownProviderError,
|
|
55
|
+
WorkspaceLockedError,
|
|
56
|
+
)
|
|
57
|
+
from cortexshift.domain.git import RepositoryInspection, RepositoryInspectionStatus
|
|
58
|
+
from cortexshift.domain.handoff import (
|
|
59
|
+
HANDOFF_PROTOCOL_VERSION,
|
|
60
|
+
HandoffFailureCode,
|
|
61
|
+
HandoffPayload,
|
|
62
|
+
HandoffRecord,
|
|
63
|
+
HandoffStatus,
|
|
64
|
+
)
|
|
65
|
+
from cortexshift.domain.identifiers import utc_now
|
|
66
|
+
from cortexshift.domain.launch import LaunchSpecification
|
|
67
|
+
from cortexshift.domain.project import Project
|
|
68
|
+
from cortexshift.domain.provider import PROVIDER_ANTIGRAVITY
|
|
69
|
+
from cortexshift.domain.session import Session, SessionExitReason
|
|
70
|
+
from cortexshift.domain.task import Task
|
|
71
|
+
from cortexshift.ports.handoff_delivery import ProviderHandoffAdapter
|
|
72
|
+
from cortexshift.ports.process_runner import InteractiveProcessRunner
|
|
73
|
+
from cortexshift.ports.provider import ManagedMcpBinder
|
|
74
|
+
from cortexshift.ports.repository import RepositoryInspector
|
|
75
|
+
from cortexshift.ports.workspace_lease import WorkspaceLeaseManager
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProviderHandoffRegistry:
|
|
79
|
+
"""Registry of supported provider handoff delivery adapters."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, adapters: list[ProviderHandoffAdapter] | None = None) -> None:
|
|
82
|
+
self._adapters: dict[str, ProviderHandoffAdapter] = {}
|
|
83
|
+
if adapters is None:
|
|
84
|
+
adapters = [
|
|
85
|
+
ClaudeHandoffAdapter(),
|
|
86
|
+
CodexHandoffAdapter(),
|
|
87
|
+
AntigravityHandoffAdapter(),
|
|
88
|
+
]
|
|
89
|
+
for adapter in adapters:
|
|
90
|
+
self._adapters[str(adapter.provider_id).lower()] = adapter
|
|
91
|
+
|
|
92
|
+
def get(self, provider_id_str: str) -> ProviderHandoffAdapter | None:
|
|
93
|
+
return self._adapters.get(provider_id_str.strip().lower())
|
|
94
|
+
|
|
95
|
+
def list_supported_ids(self) -> list[str]:
|
|
96
|
+
return sorted(self._adapters.keys())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SwitchDryRunResult(BaseModel):
|
|
100
|
+
"""Preview of what an actual switch would do, without side effects of any kind."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(frozen=True)
|
|
103
|
+
|
|
104
|
+
protocol_version: int = HANDOFF_PROTOCOL_VERSION
|
|
105
|
+
project_id: str
|
|
106
|
+
project_name: str
|
|
107
|
+
task_id: str
|
|
108
|
+
task_title: str
|
|
109
|
+
|
|
110
|
+
source_session_id: str
|
|
111
|
+
source_provider_id: str
|
|
112
|
+
source_session_status: str
|
|
113
|
+
|
|
114
|
+
target_provider_id: str
|
|
115
|
+
target_provider_name: str
|
|
116
|
+
target_native_mode: str = "new_session"
|
|
117
|
+
selected_prior_target_session_id: str | None = None
|
|
118
|
+
native_session_known: bool = False
|
|
119
|
+
native_session_id: str | None = None
|
|
120
|
+
target_executable: str
|
|
121
|
+
|
|
122
|
+
git_status: str
|
|
123
|
+
git_branch: str | None = None
|
|
124
|
+
git_head_sha: str | None = None
|
|
125
|
+
git_dirty: bool = False
|
|
126
|
+
|
|
127
|
+
delivery_strategy: str
|
|
128
|
+
bootstrap_model_turn_required: bool
|
|
129
|
+
|
|
130
|
+
context_characters: int
|
|
131
|
+
context_max_characters: int
|
|
132
|
+
context_truncated: bool
|
|
133
|
+
context_omissions: list[dict[str, Any]] = []
|
|
134
|
+
|
|
135
|
+
def to_dict(self) -> dict[str, Any]:
|
|
136
|
+
"""Serialize for machine-readable output."""
|
|
137
|
+
return self.model_dump(mode="json")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class HandoffPreviewResult(BaseModel):
|
|
141
|
+
"""A rendered, unpersisted preview of the canonical handoff for a target provider."""
|
|
142
|
+
|
|
143
|
+
model_config = ConfigDict(frozen=True)
|
|
144
|
+
|
|
145
|
+
protocol_version: int = HANDOFF_PROTOCOL_VERSION
|
|
146
|
+
project_id: str
|
|
147
|
+
target_provider_id: str
|
|
148
|
+
target_provider_name: str
|
|
149
|
+
delivery_strategy: str
|
|
150
|
+
bootstrap_model_turn_required: bool
|
|
151
|
+
payload: HandoffPayload
|
|
152
|
+
rendered_context: str
|
|
153
|
+
context_characters: int
|
|
154
|
+
context_max_characters: int
|
|
155
|
+
context_truncated: bool
|
|
156
|
+
context_omissions: list[dict[str, Any]] = []
|
|
157
|
+
|
|
158
|
+
def to_dict(self) -> dict[str, Any]:
|
|
159
|
+
"""Serialize for machine-readable output."""
|
|
160
|
+
return self.model_dump(mode="json")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SwitchResult(BaseModel):
|
|
164
|
+
"""Outcome of an executed switch.
|
|
165
|
+
|
|
166
|
+
Handoff delivery and target Session outcome are deliberately separate: context can be
|
|
167
|
+
delivered successfully and the receiving provider can still exit non-zero afterwards.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
model_config = ConfigDict(frozen=True)
|
|
171
|
+
|
|
172
|
+
handoff: HandoffRecord
|
|
173
|
+
target_session: Session
|
|
174
|
+
bootstrap_performed: bool = False
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class _SwitchContext:
|
|
179
|
+
"""Resolved, validated inputs for a handoff operation."""
|
|
180
|
+
|
|
181
|
+
project_root: Path
|
|
182
|
+
project: Project
|
|
183
|
+
task: Task
|
|
184
|
+
source_session: Session
|
|
185
|
+
adapter: ProviderHandoffAdapter
|
|
186
|
+
executable_path: str
|
|
187
|
+
store: SQLiteStateStore
|
|
188
|
+
prior_target: Session | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class SwitchService:
|
|
192
|
+
"""Orchestrates canonical handoff generation and delivery to a receiving provider."""
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
registry: ProviderHandoffRegistry | None = None,
|
|
197
|
+
native_registry: ProviderRuntimeRegistry | None = None,
|
|
198
|
+
inspector: RepositoryInspector | None = None,
|
|
199
|
+
process_runner: InteractiveProcessRunner | None = None,
|
|
200
|
+
lease_manager: WorkspaceLeaseManager | None = None,
|
|
201
|
+
builder: HandoffBuilder | None = None,
|
|
202
|
+
renderer: HandoffRenderer | None = None,
|
|
203
|
+
which_fn: Callable[[str], str | None] | None = None,
|
|
204
|
+
is_tty_fn: Callable[[], bool] | None = None,
|
|
205
|
+
checkpoint_service: CheckpointService | None = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
self._registry = registry or ProviderHandoffRegistry()
|
|
208
|
+
self._native_registry = native_registry or ProviderRuntimeRegistry()
|
|
209
|
+
self._inspector = inspector or GitRepositoryInspector()
|
|
210
|
+
self._runner = process_runner or SubprocessInteractiveProcessRunner()
|
|
211
|
+
self._lease_manager = lease_manager or FileWorkspaceLeaseManager()
|
|
212
|
+
self._builder = builder or HandoffBuilder()
|
|
213
|
+
self._renderer = renderer or HandoffRenderer()
|
|
214
|
+
self._which = which_fn if which_fn is not None else (lambda cmd: shutil.which(cmd))
|
|
215
|
+
self._is_tty = is_tty_fn if is_tty_fn is not None else self._check_tty
|
|
216
|
+
self._checkpoint_service = checkpoint_service or CheckpointService(
|
|
217
|
+
inspector=self._inspector
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _check_tty() -> bool:
|
|
222
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
223
|
+
|
|
224
|
+
# --- Context resolution ---
|
|
225
|
+
|
|
226
|
+
def _resolve_context(
|
|
227
|
+
self,
|
|
228
|
+
target_provider_name: str,
|
|
229
|
+
from_session_id: str | None,
|
|
230
|
+
start_dir: Path | str | None,
|
|
231
|
+
require_executable: bool = True,
|
|
232
|
+
) -> _SwitchContext:
|
|
233
|
+
"""Resolve and validate project, active task, source session, and target provider.
|
|
234
|
+
|
|
235
|
+
The source provider's executable is deliberately never probed: a handoff must not
|
|
236
|
+
depend on the outgoing agent still being installed or usable.
|
|
237
|
+
"""
|
|
238
|
+
start_path = Path(start_dir) if start_dir is not None else None
|
|
239
|
+
project_root = ProjectLocator.find_project_root(start_path)
|
|
240
|
+
if project_root is None:
|
|
241
|
+
raise ProjectNotInitializedError()
|
|
242
|
+
|
|
243
|
+
db_path = ProjectLocator.get_database_path(project_root)
|
|
244
|
+
store = SQLiteStateStore(db_path, auto_migrate=False)
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
project = store.get_default_project()
|
|
248
|
+
if project is None:
|
|
249
|
+
raise ProjectNotInitializedError()
|
|
250
|
+
|
|
251
|
+
active_task_id = store.get_active_task_id(project.id)
|
|
252
|
+
if not active_task_id:
|
|
253
|
+
raise NoActiveTaskError()
|
|
254
|
+
|
|
255
|
+
task = store.get_task(active_task_id)
|
|
256
|
+
if task is None:
|
|
257
|
+
raise NoActiveTaskError()
|
|
258
|
+
|
|
259
|
+
adapter = self._registry.get(target_provider_name)
|
|
260
|
+
if adapter is None:
|
|
261
|
+
raise UnknownProviderError(
|
|
262
|
+
target_provider_name, self._registry.list_supported_ids()
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
source_session = select_source_session(store, task, from_session_id)
|
|
266
|
+
|
|
267
|
+
if str(source_session.provider_id) == str(adapter.provider_id):
|
|
268
|
+
raise SameProviderSwitchError(adapter.display_name)
|
|
269
|
+
|
|
270
|
+
executable_path = ""
|
|
271
|
+
if require_executable:
|
|
272
|
+
resolved = self._which(adapter.executable)
|
|
273
|
+
if not resolved:
|
|
274
|
+
raise ProviderNotFoundError(f"{adapter.display_name} was not found in PATH.")
|
|
275
|
+
executable_path = resolved
|
|
276
|
+
|
|
277
|
+
return _SwitchContext(
|
|
278
|
+
project_root=project_root,
|
|
279
|
+
project=project,
|
|
280
|
+
task=task,
|
|
281
|
+
source_session=source_session,
|
|
282
|
+
adapter=adapter,
|
|
283
|
+
executable_path=executable_path,
|
|
284
|
+
store=store,
|
|
285
|
+
)
|
|
286
|
+
except Exception:
|
|
287
|
+
store.close()
|
|
288
|
+
raise
|
|
289
|
+
|
|
290
|
+
def _select_target(
|
|
291
|
+
self, context: _SwitchContext, new_session: bool, resume_session_id: str | None
|
|
292
|
+
) -> None:
|
|
293
|
+
if new_session and resume_session_id is not None:
|
|
294
|
+
raise NativeResumeError("--new-session and --resume-session are mutually exclusive.")
|
|
295
|
+
adapter = self._native_registry.get(str(context.adapter.provider_id))
|
|
296
|
+
if new_session:
|
|
297
|
+
context.prior_target = None
|
|
298
|
+
elif (capabilities := native_capabilities(adapter)).supports_exact_resume:
|
|
299
|
+
if capabilities.can_resume_with_followup_context:
|
|
300
|
+
context.prior_target = select_native_session(
|
|
301
|
+
context.store,
|
|
302
|
+
context.task.id,
|
|
303
|
+
context.adapter.provider_id,
|
|
304
|
+
capabilities,
|
|
305
|
+
resume_session_id,
|
|
306
|
+
)
|
|
307
|
+
elif resume_session_id is not None:
|
|
308
|
+
raise NativeResumeError("Provider cannot receive fresh context on exact resume.")
|
|
309
|
+
elif resume_session_id is not None:
|
|
310
|
+
raise NativeResumeError("Provider does not support exact resume.")
|
|
311
|
+
|
|
312
|
+
def _inspect(self, context: _SwitchContext) -> RepositoryInspection:
|
|
313
|
+
"""Inspect the live repository for the project being handed off."""
|
|
314
|
+
return self._inspector.inspect(
|
|
315
|
+
project_root=context.project_root,
|
|
316
|
+
project_id=context.project.id,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def _aggregate_task_decisions(self, context: _SwitchContext) -> list[str]:
|
|
320
|
+
"""Collect the decisions recorded across the bound task's checkpoint history.
|
|
321
|
+
|
|
322
|
+
Checkpoints stay immutable point-in-time observations, so a decision recorded by
|
|
323
|
+
`record_decision` is not copied forward into later checkpoints. Reading the whole
|
|
324
|
+
history here is what keeps such a decision in the handoff after an ordinary or
|
|
325
|
+
session-end checkpoint has since become the newest one. The query is scoped to
|
|
326
|
+
`context.task.id`, so decisions never cross task boundaries.
|
|
327
|
+
"""
|
|
328
|
+
history = context.store.list_task_checkpoint_history(context.task.id)
|
|
329
|
+
return aggregate_task_decisions(history, context.task.id)
|
|
330
|
+
|
|
331
|
+
# --- Non-mutating operations ---
|
|
332
|
+
|
|
333
|
+
def preview(
|
|
334
|
+
self,
|
|
335
|
+
target_provider_name: str,
|
|
336
|
+
from_session_id: str | None = None,
|
|
337
|
+
note: str | None = None,
|
|
338
|
+
start_dir: Path | str | None = None,
|
|
339
|
+
) -> HandoffPreviewResult:
|
|
340
|
+
"""Build and render the canonical handoff without persisting or launching anything.
|
|
341
|
+
|
|
342
|
+
Persists no handoff, captures no Git snapshot, creates no target Session, acquires
|
|
343
|
+
no long-lived workspace lease, runs no provider bootstrap, and consumes zero model
|
|
344
|
+
quota.
|
|
345
|
+
"""
|
|
346
|
+
context = self._resolve_context(
|
|
347
|
+
target_provider_name,
|
|
348
|
+
from_session_id,
|
|
349
|
+
start_dir,
|
|
350
|
+
require_executable=False,
|
|
351
|
+
)
|
|
352
|
+
try:
|
|
353
|
+
inspection = self._inspect(context)
|
|
354
|
+
latest_checkpoint = context.store.get_latest_checkpoint(context.task.id)
|
|
355
|
+
task_decisions = self._aggregate_task_decisions(context)
|
|
356
|
+
payload = self._builder.build(
|
|
357
|
+
project=context.project,
|
|
358
|
+
task=context.task,
|
|
359
|
+
source_session=context.source_session,
|
|
360
|
+
inspection=inspection,
|
|
361
|
+
target_provider_id=context.adapter.provider_id,
|
|
362
|
+
snapshot_id=None,
|
|
363
|
+
operator_note=note,
|
|
364
|
+
latest_checkpoint=latest_checkpoint,
|
|
365
|
+
task_decisions=task_decisions,
|
|
366
|
+
)
|
|
367
|
+
rendered = self._renderer.render(payload, handoff_id=None)
|
|
368
|
+
|
|
369
|
+
return HandoffPreviewResult(
|
|
370
|
+
project_id=context.project.id,
|
|
371
|
+
target_provider_id=str(context.adapter.provider_id),
|
|
372
|
+
target_provider_name=context.adapter.display_name,
|
|
373
|
+
delivery_strategy=context.adapter.delivery_strategy.value,
|
|
374
|
+
bootstrap_model_turn_required=context.adapter.bootstrap_model_turn_required,
|
|
375
|
+
payload=payload,
|
|
376
|
+
rendered_context=rendered.text,
|
|
377
|
+
context_characters=rendered.character_count,
|
|
378
|
+
context_max_characters=rendered.max_characters,
|
|
379
|
+
context_truncated=rendered.truncated,
|
|
380
|
+
context_omissions=[o.model_dump(mode="json") for o in rendered.omissions],
|
|
381
|
+
)
|
|
382
|
+
finally:
|
|
383
|
+
context.store.close()
|
|
384
|
+
|
|
385
|
+
def dry_run(
|
|
386
|
+
self,
|
|
387
|
+
target_provider_name: str,
|
|
388
|
+
from_session_id: str | None = None,
|
|
389
|
+
note: str | None = None,
|
|
390
|
+
start_dir: Path | str | None = None,
|
|
391
|
+
new_session: bool = False,
|
|
392
|
+
resume_session_id: str | None = None,
|
|
393
|
+
) -> SwitchDryRunResult:
|
|
394
|
+
"""Describe what an actual switch would do without persisting or launching anything.
|
|
395
|
+
|
|
396
|
+
Notably, a dry run of an Antigravity switch never performs the read-only plan
|
|
397
|
+
bootstrap, so it consumes no model quota.
|
|
398
|
+
"""
|
|
399
|
+
context = self._resolve_context(target_provider_name, from_session_id, start_dir)
|
|
400
|
+
try:
|
|
401
|
+
self._select_target(context, new_session, resume_session_id)
|
|
402
|
+
inspection = self._inspect(context)
|
|
403
|
+
latest_checkpoint = context.store.get_latest_checkpoint(context.task.id)
|
|
404
|
+
task_decisions = self._aggregate_task_decisions(context)
|
|
405
|
+
payload = self._builder.build(
|
|
406
|
+
project=context.project,
|
|
407
|
+
task=context.task,
|
|
408
|
+
source_session=context.source_session,
|
|
409
|
+
inspection=inspection,
|
|
410
|
+
target_provider_id=context.adapter.provider_id,
|
|
411
|
+
snapshot_id=None,
|
|
412
|
+
operator_note=note,
|
|
413
|
+
latest_checkpoint=latest_checkpoint,
|
|
414
|
+
task_decisions=task_decisions,
|
|
415
|
+
)
|
|
416
|
+
rendered = self._renderer.render(payload, handoff_id=None)
|
|
417
|
+
snapshot = inspection.snapshot
|
|
418
|
+
|
|
419
|
+
return SwitchDryRunResult(
|
|
420
|
+
project_id=context.project.id,
|
|
421
|
+
project_name=context.project.name,
|
|
422
|
+
task_id=context.task.id,
|
|
423
|
+
task_title=context.task.title,
|
|
424
|
+
source_session_id=context.source_session.id,
|
|
425
|
+
source_provider_id=str(context.source_session.provider_id),
|
|
426
|
+
source_session_status=context.source_session.status.value,
|
|
427
|
+
target_provider_id=str(context.adapter.provider_id),
|
|
428
|
+
target_provider_name=context.adapter.display_name,
|
|
429
|
+
target_executable=context.executable_path,
|
|
430
|
+
target_native_mode="resume_existing" if context.prior_target else "new_session",
|
|
431
|
+
selected_prior_target_session_id=(
|
|
432
|
+
context.prior_target.id if context.prior_target else None
|
|
433
|
+
),
|
|
434
|
+
native_session_known=context.prior_target is not None,
|
|
435
|
+
native_session_id=(
|
|
436
|
+
context.prior_target.native_session_id if context.prior_target else None
|
|
437
|
+
),
|
|
438
|
+
git_status=inspection.status.value,
|
|
439
|
+
git_branch=snapshot.branch if snapshot else None,
|
|
440
|
+
git_head_sha=snapshot.head_sha if snapshot else None,
|
|
441
|
+
git_dirty=snapshot.dirty if snapshot else False,
|
|
442
|
+
delivery_strategy=context.adapter.delivery_strategy.value,
|
|
443
|
+
bootstrap_model_turn_required=context.adapter.bootstrap_model_turn_required,
|
|
444
|
+
context_characters=rendered.character_count,
|
|
445
|
+
context_max_characters=rendered.max_characters,
|
|
446
|
+
context_truncated=rendered.truncated,
|
|
447
|
+
context_omissions=[o.model_dump(mode="json") for o in rendered.omissions],
|
|
448
|
+
)
|
|
449
|
+
finally:
|
|
450
|
+
context.store.close()
|
|
451
|
+
|
|
452
|
+
# --- Executed switch ---
|
|
453
|
+
|
|
454
|
+
def switch(
|
|
455
|
+
self,
|
|
456
|
+
target_provider_name: str,
|
|
457
|
+
from_session_id: str | None = None,
|
|
458
|
+
note: str | None = None,
|
|
459
|
+
start_dir: Path | str | None = None,
|
|
460
|
+
new_session: bool = False,
|
|
461
|
+
resume_session_id: str | None = None,
|
|
462
|
+
on_prepared: Callable[[HandoffRecord, RenderedHandoffContext], None] | None = None,
|
|
463
|
+
on_launch: Callable[[LaunchSpecification, Session], None] | None = None,
|
|
464
|
+
) -> SwitchResult:
|
|
465
|
+
"""Hand the active task off to another provider and launch the receiving agent.
|
|
466
|
+
|
|
467
|
+
The workspace lease is held continuously from repository observation through
|
|
468
|
+
target provider runtime, so the Git state described by the handoff cannot drift
|
|
469
|
+
under another CortexShift agent before the receiving agent starts.
|
|
470
|
+
|
|
471
|
+
Task progress is never mutated: `switch` changes only handoff, session, and Git
|
|
472
|
+
snapshot orchestration state.
|
|
473
|
+
"""
|
|
474
|
+
context = self._resolve_context(target_provider_name, from_session_id, start_dir)
|
|
475
|
+
store = context.store
|
|
476
|
+
|
|
477
|
+
try:
|
|
478
|
+
if not self._is_tty():
|
|
479
|
+
raise TerminalRequiredError(
|
|
480
|
+
"Handing off to an interactive provider requires a terminal (TTY).\n\n"
|
|
481
|
+
f"To preview the switch non-interactively, use: "
|
|
482
|
+
f"cortexshift switch {target_provider_name} --dry-run"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
lease = self._lease_manager.get_lease(Path(context.project.repo_path))
|
|
486
|
+
if not lease.acquire():
|
|
487
|
+
raise WorkspaceLockedError(lock_path=lease.lock_path)
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
self._select_target(context, new_session, resume_session_id)
|
|
491
|
+
inspection = self._inspect(context)
|
|
492
|
+
snapshot_id = self._persist_snapshot(inspection, store)
|
|
493
|
+
|
|
494
|
+
latest_checkpoint = store.get_latest_checkpoint(context.task.id)
|
|
495
|
+
task_decisions = self._aggregate_task_decisions(context)
|
|
496
|
+
payload = self._builder.build(
|
|
497
|
+
project=context.project,
|
|
498
|
+
task=context.task,
|
|
499
|
+
source_session=context.source_session,
|
|
500
|
+
inspection=inspection,
|
|
501
|
+
target_provider_id=context.adapter.provider_id,
|
|
502
|
+
snapshot_id=snapshot_id,
|
|
503
|
+
operator_note=note,
|
|
504
|
+
latest_checkpoint=latest_checkpoint,
|
|
505
|
+
task_decisions=task_decisions,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
handoff = HandoffRecord(
|
|
509
|
+
protocol_version=HANDOFF_PROTOCOL_VERSION,
|
|
510
|
+
project_id=context.project.id,
|
|
511
|
+
task_id=context.task.id,
|
|
512
|
+
source_session_id=context.source_session.id,
|
|
513
|
+
source_provider_id=context.source_session.provider_id,
|
|
514
|
+
target_provider_id=context.adapter.provider_id,
|
|
515
|
+
source_checkpoint_id=latest_checkpoint.id if latest_checkpoint else None,
|
|
516
|
+
git_snapshot_id=snapshot_id,
|
|
517
|
+
status=HandoffStatus.PREPARED,
|
|
518
|
+
payload=payload,
|
|
519
|
+
metadata={
|
|
520
|
+
"delivery_strategy": context.adapter.delivery_strategy.value,
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
store.save_handoff(handoff)
|
|
524
|
+
|
|
525
|
+
rendered = self._renderer.render(payload, handoff_id=handoff.id)
|
|
526
|
+
if on_prepared:
|
|
527
|
+
on_prepared(handoff, rendered)
|
|
528
|
+
|
|
529
|
+
return self._deliver(context, handoff, rendered, on_launch)
|
|
530
|
+
finally:
|
|
531
|
+
lease.release()
|
|
532
|
+
finally:
|
|
533
|
+
store.close()
|
|
534
|
+
|
|
535
|
+
def _persist_snapshot(
|
|
536
|
+
self,
|
|
537
|
+
inspection: RepositoryInspection,
|
|
538
|
+
store: SQLiteStateStore,
|
|
539
|
+
) -> str | None:
|
|
540
|
+
"""Persist the Git snapshot when Git is ready, honestly handling other states.
|
|
541
|
+
|
|
542
|
+
A known `git_not_installed` or `not_git_repository` state continues the handoff
|
|
543
|
+
with an explicit canonical marker and no snapshot row. An unexpected probe error
|
|
544
|
+
fails the switch instead of producing a package whose repository observation may
|
|
545
|
+
be unreliable. Fake snapshot rows are never created.
|
|
546
|
+
"""
|
|
547
|
+
if inspection.status == RepositoryInspectionStatus.PROBE_ERROR:
|
|
548
|
+
raise GitProbeError(
|
|
549
|
+
inspection.diagnostic
|
|
550
|
+
or "Git repository inspection failed; refusing to build a handoff "
|
|
551
|
+
"from an unreliable repository observation."
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
if inspection.status != RepositoryInspectionStatus.READY or inspection.snapshot is None:
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
store.save_snapshot(inspection.snapshot)
|
|
558
|
+
return inspection.snapshot.id
|
|
559
|
+
|
|
560
|
+
def _deliver(
|
|
561
|
+
self,
|
|
562
|
+
context: _SwitchContext,
|
|
563
|
+
handoff: HandoffRecord,
|
|
564
|
+
rendered: RenderedHandoffContext,
|
|
565
|
+
on_launch: Callable[[LaunchSpecification, Session], None] | None,
|
|
566
|
+
) -> SwitchResult:
|
|
567
|
+
"""Deliver the rendered context through the target provider's strategy."""
|
|
568
|
+
store = context.store
|
|
569
|
+
launcher = ProviderSessionLauncher(
|
|
570
|
+
process_runner=self._runner,
|
|
571
|
+
store=store,
|
|
572
|
+
checkpoint_service=self._checkpoint_service,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
target_session = launcher.start_session(
|
|
576
|
+
task_id=context.task.id,
|
|
577
|
+
provider_id=context.adapter.provider_id,
|
|
578
|
+
metadata={"handoff_id": handoff.id},
|
|
579
|
+
resumed_from_session_id=context.prior_target.id if context.prior_target else None,
|
|
580
|
+
native_session_id=(
|
|
581
|
+
context.prior_target.native_session_id if context.prior_target else None
|
|
582
|
+
),
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
preparation = context.adapter.prepare_delivery(
|
|
587
|
+
executable_path=context.executable_path,
|
|
588
|
+
project_root=Path(context.project.repo_path),
|
|
589
|
+
rendered_context=rendered.text,
|
|
590
|
+
native_session_id=(
|
|
591
|
+
context.prior_target.native_session_id if context.prior_target else None
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
except HandoffDeliveryError as err:
|
|
595
|
+
target_session = launcher.fail_session(target_session, SessionExitReason.SPAWN_FAILED)
|
|
596
|
+
self._fail_handoff(store, handoff, err.failure_code, target_session.id)
|
|
597
|
+
raise
|
|
598
|
+
|
|
599
|
+
except BaseException as err:
|
|
600
|
+
launcher.fail_session(target_session, SessionExitReason.SPAWN_FAILED)
|
|
601
|
+
self._fail_handoff(
|
|
602
|
+
store, handoff, HandoffFailureCode.BOOTSTRAP_FAILED.value, target_session.id
|
|
603
|
+
)
|
|
604
|
+
if isinstance(err, (KeyboardInterrupt, SystemExit)):
|
|
605
|
+
raise
|
|
606
|
+
raise HandoffDeliveryError(
|
|
607
|
+
"bootstrap_failed",
|
|
608
|
+
"Provider handoff preparation failed; no fallback was attempted.",
|
|
609
|
+
) from None
|
|
610
|
+
|
|
611
|
+
if preparation.native_session_id:
|
|
612
|
+
target_session = launcher.attach_native_session_id(
|
|
613
|
+
target_session, preparation.native_session_id
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
delivered = handoff.mark_delivered(
|
|
617
|
+
target_session_id=target_session.id,
|
|
618
|
+
delivered_at=utc_now(),
|
|
619
|
+
)
|
|
620
|
+
store.update_handoff_delivery(
|
|
621
|
+
delivered.id,
|
|
622
|
+
status=HandoffStatus.DELIVERED,
|
|
623
|
+
target_session_id=target_session.id,
|
|
624
|
+
delivered_at=delivered.delivered_at,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
if str(context.adapter.provider_id) == str(
|
|
628
|
+
PROVIDER_ANTIGRAVITY
|
|
629
|
+
) and not is_antigravity_mcp_configured(Path(context.project.repo_path)):
|
|
630
|
+
sys.stderr.write(f"\n{ANTIGRAVITY_MCP_MISSING_NOTICE}\n\n")
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
target_session = launcher.run(
|
|
634
|
+
target_session,
|
|
635
|
+
preparation.launch_spec,
|
|
636
|
+
on_launch=on_launch,
|
|
637
|
+
mcp_binder=(
|
|
638
|
+
context.adapter if isinstance(context.adapter, ManagedMcpBinder) else None
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
except Exception:
|
|
642
|
+
# The interactive process never started, so context was not actually delivered.
|
|
643
|
+
self._fail_handoff(
|
|
644
|
+
store, delivered, HandoffFailureCode.SPAWN_FAILED.value, target_session.id
|
|
645
|
+
)
|
|
646
|
+
raise
|
|
647
|
+
|
|
648
|
+
return SwitchResult(
|
|
649
|
+
handoff=delivered,
|
|
650
|
+
target_session=target_session,
|
|
651
|
+
bootstrap_performed=preparation.bootstrap_performed,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
@staticmethod
|
|
655
|
+
def _fail_handoff(
|
|
656
|
+
store: SQLiteStateStore,
|
|
657
|
+
handoff: HandoffRecord,
|
|
658
|
+
failure_code: str,
|
|
659
|
+
target_session_id: str | None,
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Record a safe machine classification for a failed handoff delivery."""
|
|
662
|
+
try:
|
|
663
|
+
code = HandoffFailureCode(failure_code)
|
|
664
|
+
except ValueError:
|
|
665
|
+
code = HandoffFailureCode.BOOTSTRAP_FAILED
|
|
666
|
+
store.update_handoff_delivery(
|
|
667
|
+
handoff.id,
|
|
668
|
+
status=HandoffStatus.FAILED,
|
|
669
|
+
target_session_id=target_session_id,
|
|
670
|
+
failure_code=code,
|
|
671
|
+
)
|