@michaelxwb/code-flow 0.6.4 → 0.8.1
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.
- package/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Worktree isolation contract for acceptance checks.
|
|
3
|
+
|
|
4
|
+
Isolated checks receive a deterministic namespace so parallel worktrees never
|
|
5
|
+
share ports or databases. Shared checks stay serial by default: they may pass
|
|
6
|
+
`parallel_safe: true`, but the effective answer is always false because a
|
|
7
|
+
shared service cannot be partitioned across worktrees.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import socket
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Iterator, Mapping, Optional
|
|
23
|
+
from uuid import uuid4
|
|
24
|
+
|
|
25
|
+
ENV_SCOPES = ("shared", "isolated")
|
|
26
|
+
_SAFE_COMPONENT = re.compile(r"[^a-z0-9]+")
|
|
27
|
+
_LOCK_RELATIVE = Path("code-flow/locks/shared-env.lock")
|
|
28
|
+
_LOCK_POLL_SECONDS = 0.05
|
|
29
|
+
DEFAULT_SHARED_LOCK_WAIT = 300.0
|
|
30
|
+
DEFAULT_SHARED_LOCK_STALE = 1800.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SharedEnvLockTimeout(TimeoutError):
|
|
34
|
+
"""A shared environment stayed locked past the configured wait budget."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, path: Path, waited: float) -> None:
|
|
37
|
+
super().__init__(f"shared environment lock timeout after {waited:.1f}s: {path}")
|
|
38
|
+
self.path = path
|
|
39
|
+
self.waited = waited
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def env_scope(check: Mapping[str, object]) -> str:
|
|
43
|
+
value = check.get("env_scope", "shared")
|
|
44
|
+
return value if value in ENV_SCOPES else "shared"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_isolated(check: Mapping[str, object]) -> bool:
|
|
48
|
+
return env_scope(check) == "isolated"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def effective_parallel_safe(check: Mapping[str, object]) -> bool:
|
|
52
|
+
"""Shared environments are never parallel-safe, regardless of the flag."""
|
|
53
|
+
if not is_isolated(check):
|
|
54
|
+
return False
|
|
55
|
+
declared = check.get("parallel_safe", False)
|
|
56
|
+
return declared is True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def worktree_identity(root: str, explicit: str = "") -> str:
|
|
60
|
+
for candidate in (explicit, os.environ.get("CF_WORKTREE_ID", "")):
|
|
61
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
62
|
+
return candidate.strip()
|
|
63
|
+
path = Path(root).resolve()
|
|
64
|
+
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:8]
|
|
65
|
+
return f"{path.name or 'worktree'}-{digest}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_run_id(explicit: str = "") -> str:
|
|
69
|
+
for candidate in (explicit, os.environ.get("CF_RUN_ID", "")):
|
|
70
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
71
|
+
return candidate.strip()
|
|
72
|
+
return uuid4().hex
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def port_offset(worktree_id: str) -> int:
|
|
76
|
+
digest = hashlib.sha256(worktree_id.encode("utf-8")).hexdigest()
|
|
77
|
+
return 10000 + int(digest[:4], 16) % 40000
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def db_suffix(worktree_id: str) -> str:
|
|
81
|
+
slug = _SAFE_COMPONENT.sub("_", worktree_id.strip().lower()).strip("_") or "worktree"
|
|
82
|
+
digest = hashlib.sha256(worktree_id.encode("utf-8")).hexdigest()[:6]
|
|
83
|
+
return f"_{slug}_{digest}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def worktree_namespace(root: str, worktree_id: str = "", run_id: str = "", task_id: str = "") -> dict[str, str]:
|
|
87
|
+
"""Stable per-worktree namespace injected into isolated child processes."""
|
|
88
|
+
identity = worktree_identity(root, worktree_id)
|
|
89
|
+
return {
|
|
90
|
+
"CF_WORKTREE_ID": identity,
|
|
91
|
+
"CF_RUN_ID": resolve_run_id(run_id),
|
|
92
|
+
"CF_TASK_ID": task_id or os.environ.get("CF_TASK_ID", ""),
|
|
93
|
+
"CF_PORT_OFFSET": str(port_offset(identity)),
|
|
94
|
+
"CF_DB_SUFFIX": db_suffix(identity),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def check_environment(
|
|
99
|
+
check: Mapping[str, object], root: str, namespace: Optional[Mapping[str, str]] = None
|
|
100
|
+
) -> dict[str, str]:
|
|
101
|
+
"""Environment overlay for a check: isolated only, shared is untouched."""
|
|
102
|
+
if not is_isolated(check):
|
|
103
|
+
return {}
|
|
104
|
+
resolved = dict(namespace) if namespace is not None else worktree_namespace(root)
|
|
105
|
+
return {key: str(value) for key, value in resolved.items()}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _git_common_dir(root: str) -> Path:
|
|
109
|
+
"""Directory shared by every worktree of the same repository."""
|
|
110
|
+
result = subprocess.run(
|
|
111
|
+
("git", "rev-parse", "--git-common-dir"),
|
|
112
|
+
cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False,
|
|
113
|
+
)
|
|
114
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
115
|
+
common = Path(result.stdout.strip())
|
|
116
|
+
return common if common.is_absolute() else (Path(root) / common).resolve()
|
|
117
|
+
return (Path(root) / ".code-flow").resolve()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def shared_env_lock_path(root: str) -> Path:
|
|
121
|
+
"""Lock file lives in the git common dir so all worktrees contend for it."""
|
|
122
|
+
return _git_common_dir(root) / _LOCK_RELATIVE
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _lock_payload(token: str, run_id: str, worktree_id: str) -> dict[str, object]:
|
|
126
|
+
return {
|
|
127
|
+
"token": token,
|
|
128
|
+
"pid": os.getpid(),
|
|
129
|
+
"host": socket.gethostname(),
|
|
130
|
+
"created_at": time.time(),
|
|
131
|
+
"run_id": run_id,
|
|
132
|
+
"worktree_id": worktree_id,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read_lock(path: Path) -> Optional[dict[str, object]]:
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, json.JSONDecodeError):
|
|
140
|
+
return None
|
|
141
|
+
return data if isinstance(data, dict) else None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _lock_is_stale(path: Path, stale_after: float) -> bool:
|
|
145
|
+
"""Age is authoritative; a dead local PID is stale before the timeout."""
|
|
146
|
+
try:
|
|
147
|
+
stat = path.stat()
|
|
148
|
+
except OSError:
|
|
149
|
+
return False
|
|
150
|
+
data = _read_lock(path)
|
|
151
|
+
if data is None:
|
|
152
|
+
# A writer may be between O_CREAT and its first write; only break files
|
|
153
|
+
# that stayed unreadable beyond the grace window.
|
|
154
|
+
grace = min(max(stale_after, 0.1), 1.0)
|
|
155
|
+
return time.time() - stat.st_mtime >= grace
|
|
156
|
+
if data.get("host") == socket.gethostname():
|
|
157
|
+
pid = data.get("pid")
|
|
158
|
+
if isinstance(pid, int) and pid > 0:
|
|
159
|
+
try:
|
|
160
|
+
os.kill(pid, 0)
|
|
161
|
+
except ProcessLookupError:
|
|
162
|
+
return True
|
|
163
|
+
except (PermissionError, OSError):
|
|
164
|
+
return False
|
|
165
|
+
# A live local owner is never stale purely because the run is long.
|
|
166
|
+
return False
|
|
167
|
+
created = data.get("created_at")
|
|
168
|
+
if isinstance(created, (int, float)) and time.time() - float(created) >= stale_after:
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _acquire_shared_lock(path: Path, wait: float, stale_after: float, token: str,
|
|
174
|
+
run_id: str, worktree_id: str) -> None:
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
deadline = time.monotonic() + wait
|
|
177
|
+
while True:
|
|
178
|
+
try:
|
|
179
|
+
descriptor = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
180
|
+
except FileExistsError:
|
|
181
|
+
if _lock_is_stale(path, stale_after):
|
|
182
|
+
try:
|
|
183
|
+
path.unlink()
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
pass
|
|
186
|
+
continue
|
|
187
|
+
remaining = deadline - time.monotonic()
|
|
188
|
+
if remaining <= 0:
|
|
189
|
+
raise SharedEnvLockTimeout(path, wait) from None
|
|
190
|
+
time.sleep(min(_LOCK_POLL_SECONDS, remaining))
|
|
191
|
+
continue
|
|
192
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
193
|
+
handle.write(json.dumps(_lock_payload(token, run_id, worktree_id), ensure_ascii=False))
|
|
194
|
+
handle.flush()
|
|
195
|
+
os.fsync(handle.fileno())
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _release_shared_lock(path: Path, token: str) -> None:
|
|
200
|
+
data = _read_lock(path)
|
|
201
|
+
if not isinstance(data, dict) or data.get("token") != token:
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
path.unlink()
|
|
205
|
+
except FileNotFoundError:
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@contextmanager
|
|
210
|
+
def shared_env_lock(
|
|
211
|
+
root: str,
|
|
212
|
+
*,
|
|
213
|
+
wait: Optional[float] = None,
|
|
214
|
+
stale_after: Optional[float] = None,
|
|
215
|
+
run_id: str = "",
|
|
216
|
+
worktree_id: str = "",
|
|
217
|
+
) -> Iterator[dict[str, object]]:
|
|
218
|
+
"""Cross-worktree lock for shared (non-partitionable) environment checks.
|
|
219
|
+
|
|
220
|
+
The lock is a JSON sidecar in the git common directory with an owner token.
|
|
221
|
+
Wait and stale budgets are explicit so a crashed worktree cannot block the
|
|
222
|
+
batch forever, while a live owner keeps the lock until released.
|
|
223
|
+
"""
|
|
224
|
+
path = shared_env_lock_path(root)
|
|
225
|
+
resolved_wait = DEFAULT_SHARED_LOCK_WAIT if wait is None else max(0.0, float(wait))
|
|
226
|
+
resolved_stale = DEFAULT_SHARED_LOCK_STALE if stale_after is None else max(0.1, float(stale_after))
|
|
227
|
+
token = uuid4().hex
|
|
228
|
+
identity = worktree_id or worktree_identity(root)
|
|
229
|
+
key = run_id or resolve_run_id()
|
|
230
|
+
_acquire_shared_lock(path, resolved_wait, resolved_stale, token, key, identity)
|
|
231
|
+
try:
|
|
232
|
+
yield {"path": str(path), "token": token, "run_id": key, "worktree_id": identity}
|
|
233
|
+
finally:
|
|
234
|
+
_release_shared_lock(path, token)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def preflight_commands(check: Mapping[str, object]) -> list[list[str]]:
|
|
238
|
+
"""Normalize preflight entries (argv array or shell words) to argv arrays."""
|
|
239
|
+
raw = check.get("preflight", [])
|
|
240
|
+
if not isinstance(raw, list):
|
|
241
|
+
raise ValueError("invalid check preflight: expected list")
|
|
242
|
+
commands: list[list[str]] = []
|
|
243
|
+
for entry in raw:
|
|
244
|
+
if isinstance(entry, str):
|
|
245
|
+
argv = shlex.split(entry)
|
|
246
|
+
elif isinstance(entry, list) and all(isinstance(token, str) for token in entry):
|
|
247
|
+
argv = list(entry)
|
|
248
|
+
else:
|
|
249
|
+
raise ValueError("invalid check preflight entry: expected argv array or command string")
|
|
250
|
+
if not argv or any(not token for token in argv):
|
|
251
|
+
raise ValueError("invalid check preflight entry: empty argv")
|
|
252
|
+
commands.append(argv)
|
|
253
|
+
return commands
|
|
File without changes
|