tamfis-code 0.2.3__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.
tamfis_code/state.py ADDED
@@ -0,0 +1,449 @@
1
+ """Durable local per-session CLI state: last received event id/task id per session,
2
+ so `attach`/`logs --follow` can resume a stream without a full replay-from-
3
+ zero, and so a bare `tamfis-code agents`/`status` can show "what was I last
4
+ doing" without another round trip.
5
+
6
+ This is client-side bookkeeping ONLY -- the server (RemoteEvent/RemoteTask
7
+ tables) remains the single source of truth for everything that must survive
8
+ a lost or wiped local state file; losing this file just means the next
9
+ `attach`/`logs --follow` replays from sequence 0 instead of resuming exactly
10
+ where it left off, not that any task/event data is lost.
11
+
12
+ Reuses the same CONFIG_DIR credentials.json/config.toml already live in
13
+ (Phase 18's "current canonical equivalent" allowance), rather than
14
+ introducing a second state directory.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import stat
23
+ import sys
24
+ import tempfile
25
+ import uuid
26
+ from dataclasses import asdict, dataclass, field
27
+ from datetime import datetime, timezone
28
+ from typing import Any, Optional
29
+
30
+ from .config import CONFIG_DIR
31
+
32
+ STATE_PATH = CONFIG_DIR / "state.json"
33
+ MAX_ACTION_HISTORY = 250
34
+ MAX_CHECKPOINTS = 50
35
+ MAX_SAVED_PLANS = 50
36
+
37
+ _SECRET_PATTERNS = (
38
+ re.compile(r"(?i)\b(authorization\s*:\s*bearer\s+)([^\s]+)"),
39
+ re.compile(r"(?i)\b(password|passwd|token|access_token|refresh_token|api[_-]?key|client_secret)\s*([=:])\s*([^\s&]+)"),
40
+ re.compile(r"\beyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
41
+ )
42
+
43
+
44
+ def _now() -> str:
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+
48
+ def redact_secrets(text: str) -> str:
49
+ value = text
50
+ value = _SECRET_PATTERNS[0].sub(r"\1[REDACTED]", value)
51
+ value = _SECRET_PATTERNS[1].sub(r"\1\2[REDACTED]", value)
52
+ value = _SECRET_PATTERNS[2].sub("[REDACTED_JWT]", value)
53
+ return value
54
+
55
+
56
+ def _sanitize(value: Any) -> Any:
57
+ if isinstance(value, str):
58
+ return redact_secrets(value)
59
+ if isinstance(value, list):
60
+ return [_sanitize(item) for item in value]
61
+ if isinstance(value, dict):
62
+ return {key: _sanitize(item) for key, item in value.items()}
63
+ return value
64
+
65
+
66
+ @dataclass
67
+ class QueuedInstruction:
68
+ id: str
69
+ text: str
70
+ classification: str = "append"
71
+ priority: int = 100
72
+ status: str = "queued"
73
+ created_at: str = field(default_factory=_now)
74
+
75
+
76
+ @dataclass
77
+ class AgentAction:
78
+ id: str
79
+ type: str
80
+ purpose: str
81
+ status: str = "planned"
82
+ risk: str = "read_only"
83
+ detail: str = ""
84
+ depends_on: list[str] = field(default_factory=list)
85
+ started_at: Optional[str] = None
86
+ completed_at: Optional[str] = None
87
+ result_summary: str = ""
88
+ attempts: int = 0
89
+ last_error: str = ""
90
+
91
+
92
+ @dataclass
93
+ class CodePlan:
94
+ id: str
95
+ objective: str
96
+ content: str
97
+ status: str = "ready"
98
+ source_task_id: Optional[str] = None
99
+ execution_task_id: Optional[str] = None
100
+ created_at: str = field(default_factory=_now)
101
+ updated_at: str = field(default_factory=_now)
102
+ # Structured mirror of the server's `plan_created` event payload (a list
103
+ # of {"step": str, "status": str} items), plus a client-assigned `index`
104
+ # since the server payload carries no stable step id of its own. `content`
105
+ # remains the human-readable markdown fallback -- this is additive, not a
106
+ # replacement, so nothing that reads `content` today needs to change.
107
+ steps: list[dict[str, Any]] = field(default_factory=list)
108
+
109
+
110
+ @dataclass
111
+ class SessionState:
112
+ session_id: int
113
+ workspace_root: str = ""
114
+ primary_workspace: str = ""
115
+ allowed_workspaces: list[str] = field(default_factory=list)
116
+ repository_root: Optional[str] = None
117
+ current_working_directory: str = ""
118
+ active_branch: Optional[str] = None
119
+ last_event_id: int = 0
120
+ last_task_id: Optional[str] = None
121
+ active_task: Optional[dict[str, Any]] = None
122
+ current_phase: str = "idle"
123
+ execution_status: str = "idle"
124
+ inspected_files: dict[str, dict[str, Any]] = field(default_factory=dict)
125
+ discovered_symbols: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
126
+ discovered_services: list[dict[str, Any]] = field(default_factory=list)
127
+ discovered_reports: list[dict[str, Any]] = field(default_factory=list)
128
+ repository_context: dict[str, Any] = field(default_factory=dict)
129
+ completed_actions: list[dict[str, Any]] = field(default_factory=list)
130
+ pending_actions: list[dict[str, Any]] = field(default_factory=list)
131
+ queued_user_instructions: list[dict[str, Any]] = field(default_factory=list)
132
+ modified_files: list[dict[str, Any]] = field(default_factory=list)
133
+ validation_results: list[dict[str, Any]] = field(default_factory=list)
134
+ unresolved_issues: list[dict[str, Any]] = field(default_factory=list)
135
+ running_action: Optional[dict[str, Any]] = None
136
+ conversation_summary: str = ""
137
+ context_checkpoints: list[dict[str, Any]] = field(default_factory=list)
138
+ saved_plans: list[dict[str, Any]] = field(default_factory=list)
139
+ active_plan_id: Optional[str] = None
140
+ discovery_fingerprint: str = ""
141
+ selected_model: str = "auto"
142
+ selected_provider: Optional[str] = None
143
+ updated_at: str = ""
144
+
145
+
146
+ def _load_raw() -> dict[str, Any]:
147
+ if not STATE_PATH.is_file():
148
+ return {}
149
+ try:
150
+ payload = json.loads(STATE_PATH.read_text(encoding="utf-8"))
151
+ return payload if isinstance(payload, dict) else {}
152
+ except OSError as exc:
153
+ # A permission/ownership mismatch here silently resets every session
154
+ # to blank (no active_plan_id, no saved_plans, no conversation_summary)
155
+ # -- the CLI then looks "amnesiac", re-proposing the same plan every
156
+ # invocation with no memory of prior progress. Surface it instead of
157
+ # swallowing it so that symptom is diagnosable.
158
+ print(
159
+ f"⚠ Could not read local session state at {STATE_PATH} ({exc}). "
160
+ "Continuing with a blank session -- prior plan/task memory is unavailable "
161
+ "until this is fixed (likely an ownership/permission mismatch on "
162
+ f"{CONFIG_DIR}).",
163
+ file=sys.stderr,
164
+ )
165
+ return {}
166
+ except json.JSONDecodeError:
167
+ return {}
168
+
169
+
170
+ def _save_raw(data: dict[str, Any]) -> None:
171
+ """Atomically replace state so a killed CLI cannot leave invalid JSON."""
172
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
173
+ # Only chmod when it isn't already owner-only -- calling this unconditionally
174
+ # on every save raises PermissionError (uncaught, all the way up) the moment
175
+ # CONFIG_DIR is ever owned by a different user than the caller, which used to
176
+ # crash the whole CLI on its very first state write.
177
+ if stat.S_IMODE(os.stat(CONFIG_DIR).st_mode) != stat.S_IRWXU:
178
+ os.chmod(CONFIG_DIR, stat.S_IRWXU)
179
+ fd, temp_name = tempfile.mkstemp(prefix=".state-", suffix=".json", dir=CONFIG_DIR)
180
+ try:
181
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
182
+ json.dump(_sanitize(data), handle, indent=2, sort_keys=True)
183
+ handle.flush()
184
+ os.fsync(handle.fileno())
185
+ os.chmod(temp_name, stat.S_IRUSR | stat.S_IWUSR)
186
+ os.replace(temp_name, STATE_PATH)
187
+ finally:
188
+ try:
189
+ os.unlink(temp_name)
190
+ except FileNotFoundError:
191
+ pass
192
+
193
+
194
+ def get_session_state(session_id: int) -> SessionState:
195
+ raw = _load_raw().get(str(session_id))
196
+ if not raw:
197
+ return SessionState(session_id=session_id)
198
+ allowed = set(SessionState.__dataclass_fields__)
199
+ values = {key: value for key, value in raw.items() if key in allowed and key != "session_id"}
200
+ values["last_event_id"] = int(values.get("last_event_id") or 0)
201
+ return SessionState(session_id=session_id, **values)
202
+
203
+
204
+ def put_session_state(state: SessionState) -> None:
205
+ data = _load_raw()
206
+ latest = data.get(str(state.session_id), {})
207
+ # A second `tamfis-code queue ...` process may add an instruction while
208
+ # the streaming process is saving an event cursor. Merge by id so the
209
+ # cursor write cannot erase that newly queued user input.
210
+ if isinstance(latest, dict):
211
+ merged_queue = {item.get("id"): item for item in latest.get("queued_user_instructions", []) if item.get("id")}
212
+ merged_queue.update({item.get("id"): item for item in state.queued_user_instructions if item.get("id")})
213
+ state.queued_user_instructions = sorted(
214
+ merged_queue.values(), key=lambda item: (int(item.get("priority", 100)), item.get("created_at", ""))
215
+ )
216
+ # Event-cursor updates can race a foreground plan save in the same
217
+ # way they race queued instructions. Preserve plans by id as well.
218
+ merged_plans = {item.get("id"): item for item in latest.get("saved_plans", []) if item.get("id")}
219
+ merged_plans.update({item.get("id"): item for item in state.saved_plans if item.get("id")})
220
+ state.saved_plans = sorted(
221
+ merged_plans.values(), key=lambda item: item.get("created_at", "")
222
+ )[-MAX_SAVED_PLANS:]
223
+ state.updated_at = _now()
224
+ data[str(state.session_id)] = asdict(state)
225
+ _save_raw(data)
226
+
227
+
228
+ def save_session_state(
229
+ session_id: int, *, workspace_root: Optional[str] = None,
230
+ last_event_id: Optional[int] = None, last_task_id: Optional[str] = None,
231
+ **updates: Any,
232
+ ) -> None:
233
+ state = get_session_state(session_id)
234
+ if workspace_root is not None:
235
+ state.workspace_root = workspace_root
236
+ if not state.primary_workspace:
237
+ state.primary_workspace = workspace_root
238
+ if workspace_root not in state.allowed_workspaces:
239
+ state.allowed_workspaces.append(workspace_root)
240
+ if not state.current_working_directory:
241
+ state.current_working_directory = workspace_root
242
+ if last_event_id is not None:
243
+ # Sequence ids only ever move forward for a given session -- never
244
+ # regress state from a stale/out-of-order write (e.g. a slower
245
+ # concurrent `logs --follow` process finishing after a newer one).
246
+ state.last_event_id = max(int(last_event_id), int(state.last_event_id or 0))
247
+ if last_task_id is not None:
248
+ state.last_task_id = last_task_id
249
+ for key, value in updates.items():
250
+ if key in SessionState.__dataclass_fields__ and key != "session_id":
251
+ setattr(state, key, value)
252
+ put_session_state(state)
253
+
254
+
255
+ def start_action(session_id: int, *, action_type: str, purpose: str,
256
+ risk: str = "read_only", detail: str = "") -> AgentAction:
257
+ state = get_session_state(session_id)
258
+ action = AgentAction(
259
+ id=f"action_{uuid.uuid4().hex[:12]}", type=action_type, purpose=purpose,
260
+ risk=risk, detail=detail, status="running", started_at=_now(),
261
+ )
262
+ state.running_action = asdict(action)
263
+ state.pending_actions.append(asdict(action))
264
+ state.execution_status = "running"
265
+ put_session_state(state)
266
+ return action
267
+
268
+
269
+ # Number of consecutive failures for the same action `purpose` before it's
270
+ # escalated into `unresolved_issues` -- below this, a single failure is just
271
+ # noise (transient network blip, etc), not yet "the agent is stuck".
272
+ FAILURE_ESCALATION_THRESHOLD = 2
273
+
274
+
275
+ def finish_action(session_id: int, action_id: str, *, status: str, summary: str = "", error: str = "") -> None:
276
+ state = get_session_state(session_id)
277
+ finished = None
278
+ remaining = []
279
+ for action in state.pending_actions:
280
+ if action.get("id") == action_id:
281
+ action.update(status=status, completed_at=_now(), result_summary=summary)
282
+ if status == "failed":
283
+ action["last_error"] = error or summary
284
+ purpose = action.get("purpose", "")
285
+ prior_failures = sum(
286
+ 1 for completed in state.completed_actions
287
+ if completed.get("purpose") == purpose and completed.get("status") == "failed"
288
+ )
289
+ action["attempts"] = prior_failures + 1
290
+ if action["attempts"] >= FAILURE_ESCALATION_THRESHOLD and not any(
291
+ issue.get("type") == "repeated_action_failure" and issue.get("purpose") == purpose
292
+ for issue in state.unresolved_issues
293
+ ):
294
+ state.unresolved_issues.append({
295
+ "type": "repeated_action_failure", "status": "needs_attention",
296
+ "purpose": purpose, "attempts": action["attempts"],
297
+ "detail": (
298
+ f"'{purpose}' has failed {action['attempts']} times in a row -- "
299
+ "consider a different approach instead of retrying as-is."
300
+ ),
301
+ })
302
+ finished = action
303
+ else:
304
+ remaining.append(action)
305
+ state.pending_actions = remaining
306
+ if finished:
307
+ state.completed_actions = (state.completed_actions + [finished])[-MAX_ACTION_HISTORY:]
308
+ if state.running_action and state.running_action.get("id") == action_id:
309
+ state.running_action = None
310
+ state.execution_status = "idle" if state.running_action is None else "running"
311
+ put_session_state(state)
312
+
313
+
314
+ def enqueue_instruction(session_id: int, text: str, *, classification: str = "append",
315
+ priority: int = 100) -> QueuedInstruction:
316
+ state = get_session_state(session_id)
317
+ item = QueuedInstruction(id=f"instruction_{uuid.uuid4().hex[:10]}", text=text,
318
+ classification=classification, priority=priority)
319
+ state.queued_user_instructions.append(asdict(item))
320
+ state.queued_user_instructions.sort(
321
+ key=lambda value: (int(value.get("priority", 100)), value.get("created_at", ""))
322
+ )
323
+ put_session_state(state)
324
+ return item
325
+
326
+
327
+ def update_instruction(session_id: int, instruction_id: str, status: str) -> bool:
328
+ state = get_session_state(session_id)
329
+ for item in state.queued_user_instructions:
330
+ if item.get("id") == instruction_id:
331
+ item["status"] = status
332
+ put_session_state(state)
333
+ return True
334
+ return False
335
+
336
+
337
+ def checkpoint(session_id: int, *, reason: str, summary: str = "") -> None:
338
+ state = get_session_state(session_id)
339
+ state.context_checkpoints = (state.context_checkpoints + [{
340
+ "created_at": _now(), "reason": reason, "phase": state.current_phase,
341
+ "task_id": state.last_task_id, "last_event_id": state.last_event_id,
342
+ "summary": summary,
343
+ }])[-MAX_CHECKPOINTS:]
344
+ put_session_state(state)
345
+
346
+
347
+ def save_plan(
348
+ session_id: int, *, objective: str, content: str,
349
+ source_task_id: Optional[str] = None,
350
+ steps: Optional[list[dict[str, Any]]] = None,
351
+ ) -> CodePlan:
352
+ """Persist a completed planning result as an executable plan."""
353
+ state = get_session_state(session_id)
354
+ plan = CodePlan(
355
+ id=f"plan_{uuid.uuid4().hex[:10]}",
356
+ objective=objective.strip(), content=content.strip(),
357
+ source_task_id=source_task_id,
358
+ steps=[
359
+ {**step, "index": index}
360
+ for index, step in enumerate(steps or [])
361
+ if isinstance(step, dict)
362
+ ],
363
+ )
364
+ state.saved_plans = (state.saved_plans + [asdict(plan)])[-MAX_SAVED_PLANS:]
365
+ state.active_plan_id = plan.id
366
+ put_session_state(state)
367
+ return plan
368
+
369
+
370
+ def get_plan(session_id: int, plan_id: Optional[str] = None) -> Optional[dict[str, Any]]:
371
+ """Return the selected plan, accepting an exact id, unique prefix, or latest."""
372
+ state = get_session_state(session_id)
373
+ plans = state.saved_plans
374
+ if not plans:
375
+ return None
376
+ wanted = (plan_id or state.active_plan_id or "").strip()
377
+ if not wanted:
378
+ return plans[-1]
379
+ exact = next((item for item in plans if item.get("id") == wanted), None)
380
+ if exact:
381
+ return exact
382
+ matches = [item for item in plans if str(item.get("id", "")).startswith(wanted)]
383
+ return matches[0] if len(matches) == 1 else None
384
+
385
+
386
+ def update_plan(
387
+ session_id: int, plan_id: str, *, status: str,
388
+ execution_task_id: Optional[str] = None,
389
+ ) -> Optional[dict[str, Any]]:
390
+ state = get_session_state(session_id)
391
+ updated = None
392
+ for item in state.saved_plans:
393
+ if item.get("id") == plan_id:
394
+ item["status"] = status
395
+ item["updated_at"] = _now()
396
+ if execution_task_id is not None:
397
+ item["execution_task_id"] = execution_task_id
398
+ updated = item
399
+ break
400
+ if updated is not None:
401
+ state.active_plan_id = plan_id
402
+ put_session_state(state)
403
+ return updated
404
+
405
+
406
+ def update_plan_steps(session_id: int, plan_id: str, items: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
407
+ """Persist the server's `plan_created.items` onto the matching saved plan.
408
+
409
+ Today the server only ever emits this list once per plan and never
410
+ re-references individual steps afterward, so `steps` reflects whatever
411
+ the most recent `plan_created` event said -- there's no per-step
412
+ completion event to key off yet.
413
+ """
414
+ state = get_session_state(session_id)
415
+ updated = None
416
+ for item in state.saved_plans:
417
+ if item.get("id") == plan_id:
418
+ item["steps"] = [
419
+ {**step, "index": index}
420
+ for index, step in enumerate(items)
421
+ if isinstance(step, dict)
422
+ ]
423
+ item["updated_at"] = _now()
424
+ updated = item
425
+ break
426
+ if updated is not None:
427
+ put_session_state(state)
428
+ return updated
429
+
430
+
431
+ def plan_execution_objective(plan: dict[str, Any]) -> str:
432
+ """Turn a reviewed saved plan into an unambiguous agent execution task."""
433
+ return (
434
+ "Execute the saved engineering plan below. Inspect the current workspace first and "
435
+ "adapt only where repository drift requires it. Implement the work, run proportionate "
436
+ "validation, and report concrete results. Do not merely restate the plan.\n\n"
437
+ f"Original objective:\n{plan.get('objective', '')}\n\n"
438
+ f"Saved plan ({plan.get('id', 'unknown')}):\n{plan.get('content', '')}"
439
+ )
440
+
441
+
442
+ def all_known_session_ids() -> list[int]:
443
+ ids = []
444
+ for key in _load_raw().keys():
445
+ try:
446
+ ids.append(int(key))
447
+ except ValueError:
448
+ continue
449
+ return sorted(ids)
tamfis_code/tasks.py ADDED
@@ -0,0 +1,42 @@
1
+ """Task discovery for `/resume` and `/retry` -- there is no dedicated "list
2
+ tasks for a session" endpoint (see docs/REMOTE_AGENT_MASTER_SPEC.md Phase 21;
3
+ not adding one this pass to keep the shared API surface unchanged), so the
4
+ most-recent-task lookup is derived from GET /sessions/{id}/thread's message
5
+ list the same way the web SessionView.tsx reconstructs AITurns on refresh --
6
+ distinct task_ids in reverse-chronological order, then GET /tasks/{id} for
7
+ status until a match is found.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ from .api_client import RemoteAPIClient
15
+
16
+ # Bounded lookback rather than scanning a session's entire history -- a
17
+ # session that has run hundreds of tasks should not make this many
18
+ # GET /tasks/{id} round trips just to find one recent failure.
19
+ DEFAULT_LOOKBACK = 15
20
+
21
+
22
+ async def find_recent_task(
23
+ client: RemoteAPIClient, session_id: int, *, only_status: Optional[set[str]] = None,
24
+ lookback: int = DEFAULT_LOOKBACK,
25
+ ) -> Optional[dict]:
26
+ thread = await client.get_thread(session_id, after_sequence=0)
27
+ messages = thread.get("messages") or []
28
+
29
+ candidate_ids: list[str] = []
30
+ for message in reversed(messages): # thread messages are oldest-first
31
+ task_id = message.get("task_id")
32
+ if not task_id or task_id in candidate_ids:
33
+ continue
34
+ candidate_ids.append(task_id)
35
+ if len(candidate_ids) >= lookback:
36
+ break
37
+
38
+ for task_id in candidate_ids:
39
+ task = await client.get_task(task_id)
40
+ if only_status is None or str(task.get("status")) in only_status:
41
+ return task
42
+ return None