@opengsd/gsd-path 1.6.2 → 1.7.0
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/CHANGELOG.md +21 -0
- package/DOCS.md +27 -1
- package/FULL.md +2 -1
- package/HOOKS.md +38 -4
- package/QUICK.md +1 -1
- package/README.md +19 -11
- package/RUNTIME.md +5 -3
- package/WORKFLOW.md +9 -2
- package/package.json +1 -1
- package/scripts/_common.py +76 -2
- package/scripts/archive_milestone.py +129 -7
- package/scripts/build_recovery.py +25 -2
- package/scripts/build_state.py +333 -8
- package/scripts/check_handoffs.py +437 -30
- package/scripts/check_task_briefs.py +135 -11
- package/scripts/discussion_validate.py +46 -10
- package/scripts/dispatch_driver.py +28 -3
- package/scripts/git_guard.py +44 -4
- package/scripts/guard_hook.py +456 -9
- package/scripts/install.py +34 -4
- package/scripts/integration.py +196 -39
- package/scripts/isolation.py +996 -38
- package/scripts/lean_verification.py +28 -13
- package/scripts/loop_run.py +9 -4
- package/scripts/members.py +17 -3
- package/scripts/pipeline_diagnose.py +104 -1
- package/scripts/pipeline_state.py +10 -2
- package/scripts/runtime_store.py +88 -3
- package/scripts/state_checkpoint.py +15 -0
- package/scripts/status_runtime.py +73 -2
- package/scripts/workflow_run.py +10 -3
- package/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/BUILD.md +3 -1
- package/skills/gsd-path/DEFINE.md +2 -1
- package/skills/gsd-path/PLAN.md +10 -0
- package/skills/gsd-path/SHIP.md +41 -8
- package/skills/gsd-path/SKILL.md +16 -1
- package/skills/gsd-path/references/build-native.md +20 -3
- package/skills/gsd-path/references/coder.md +6 -3
- package/skills/gsd-path/references/reviewer-final.md +10 -0
- package/skills/gsd-path/references/reviewer-wave.md +61 -22
- package/skills/gsd-path/references/reviewer.md +8 -6
- package/skills/gsd-path/references/runtime-selection.md +36 -0
- package/skills/gsd-path/references/spec-probes.md +19 -1
- package/skills/gsd-path/scripts/_common.py +76 -2
- package/skills/gsd-path/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path/scripts/build_recovery.py +25 -2
- package/skills/gsd-path/scripts/build_state.py +333 -8
- package/skills/gsd-path/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path/scripts/dispatch_driver.py +28 -3
- package/skills/gsd-path/scripts/git_guard.py +44 -4
- package/skills/gsd-path/scripts/integration.py +196 -39
- package/skills/gsd-path/scripts/isolation.py +996 -38
- package/skills/gsd-path/scripts/lean_verification.py +28 -13
- package/skills/gsd-path/scripts/loop_run.py +9 -4
- package/skills/gsd-path/scripts/members.py +17 -3
- package/skills/gsd-path/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path/scripts/status_runtime.py +73 -2
- package/skills/gsd-path/scripts/workflow_run.py +10 -3
- package/skills/gsd-path/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/templates/intent.md +5 -3
- package/skills/gsd-path/templates/task.md +8 -1
- package/skills/gsd-path/templates/wave-review.md +24 -8
- package/skills/gsd-path-build/SKILL.md +3 -1
- package/skills/gsd-path-build/references/build-native.md +20 -3
- package/skills/gsd-path-build/references/coder.md +6 -3
- package/skills/gsd-path-build/references/reviewer-final.md +10 -0
- package/skills/gsd-path-build/references/reviewer-wave.md +61 -22
- package/skills/gsd-path-build/references/reviewer.md +8 -6
- package/skills/gsd-path-build/references/runtime-selection.md +36 -0
- package/skills/gsd-path-build/references/spec-probes.md +19 -1
- package/skills/gsd-path-build/scripts/_common.py +76 -2
- package/skills/gsd-path-build/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-build/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-build/scripts/build_state.py +333 -8
- package/skills/gsd-path-build/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-build/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-build/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-build/scripts/dispatch_driver.py +28 -3
- package/skills/gsd-path-build/scripts/integration.py +196 -39
- package/skills/gsd-path-build/scripts/isolation.py +996 -38
- package/skills/gsd-path-build/scripts/loop_run.py +9 -4
- package/skills/gsd-path-build/scripts/members.py +17 -3
- package/skills/gsd-path-build/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-build/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-build/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-build/templates/task.md +8 -1
- package/skills/gsd-path-build/templates/wave-review.md +24 -8
- package/skills/gsd-path-decide/references/runtime-selection.md +36 -0
- package/skills/gsd-path-decide/scripts/_common.py +76 -2
- package/skills/gsd-path-decide/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-decide/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-decide/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-decide/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-decide/scripts/integration.py +196 -39
- package/skills/gsd-path-decide/scripts/isolation.py +996 -38
- package/skills/gsd-path-decide/scripts/loop_run.py +9 -4
- package/skills/gsd-path-decide/scripts/members.py +17 -3
- package/skills/gsd-path-decide/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-decide/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/SKILL.md +2 -1
- package/skills/gsd-path-define/references/runtime-selection.md +36 -0
- package/skills/gsd-path-define/references/spec-probes.md +19 -1
- package/skills/gsd-path-define/scripts/_common.py +76 -2
- package/skills/gsd-path-define/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-define/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-define/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-define/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-define/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-define/scripts/integration.py +196 -39
- package/skills/gsd-path-define/scripts/isolation.py +996 -38
- package/skills/gsd-path-define/scripts/loop_run.py +9 -4
- package/skills/gsd-path-define/scripts/members.py +17 -3
- package/skills/gsd-path-define/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-define/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-define/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/templates/intent.md +5 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +36 -0
- package/skills/gsd-path-discuss/scripts/_common.py +76 -2
- package/skills/gsd-path-discuss/scripts/loop_run.py +9 -4
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +36 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +76 -2
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-docs-audit/scripts/integration.py +196 -39
- package/skills/gsd-path-docs-audit/scripts/isolation.py +996 -38
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +9 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +17 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-forensics/references/runtime-selection.md +36 -0
- package/skills/gsd-path-forensics/scripts/_common.py +76 -2
- package/skills/gsd-path-forensics/scripts/loop_run.py +9 -4
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +104 -1
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-inspect/references/runtime-selection.md +36 -0
- package/skills/gsd-path-inspect/scripts/_common.py +76 -2
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-inspect/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-inspect/scripts/integration.py +196 -39
- package/skills/gsd-path-inspect/scripts/isolation.py +996 -38
- package/skills/gsd-path-inspect/scripts/loop_run.py +9 -4
- package/skills/gsd-path-inspect/scripts/members.py +17 -3
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-inspect/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-loop/references/runtime-selection.md +36 -0
- package/skills/gsd-path-loop/scripts/_common.py +76 -2
- package/skills/gsd-path-loop/scripts/loop_run.py +9 -4
- package/skills/gsd-path-loop/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-migrate/references/runtime-selection.md +36 -0
- package/skills/gsd-path-migrate/scripts/_common.py +76 -2
- package/skills/gsd-path-migrate/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/SKILL.md +10 -0
- package/skills/gsd-path-plan/references/runtime-selection.md +36 -0
- package/skills/gsd-path-plan/references/spec-probes.md +19 -1
- package/skills/gsd-path-plan/scripts/_common.py +76 -2
- package/skills/gsd-path-plan/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-plan/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-plan/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-plan/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-plan/scripts/integration.py +196 -39
- package/skills/gsd-path-plan/scripts/isolation.py +996 -38
- package/skills/gsd-path-plan/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/scripts/members.py +17 -3
- package/skills/gsd-path-plan/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-plan/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-plan/templates/task.md +8 -1
- package/skills/gsd-path-research/references/runtime-selection.md +36 -0
- package/skills/gsd-path-research/scripts/_common.py +76 -2
- package/skills/gsd-path-research/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-research/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-research/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-research/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-research/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-research/scripts/integration.py +196 -39
- package/skills/gsd-path-research/scripts/isolation.py +996 -38
- package/skills/gsd-path-research/scripts/loop_run.py +9 -4
- package/skills/gsd-path-research/scripts/members.py +17 -3
- package/skills/gsd-path-research/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-research/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-research/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-roadmap/references/runtime-selection.md +36 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +76 -2
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-roadmap/scripts/integration.py +196 -39
- package/skills/gsd-path-roadmap/scripts/isolation.py +996 -38
- package/skills/gsd-path-roadmap/scripts/loop_run.py +9 -4
- package/skills/gsd-path-roadmap/scripts/members.py +17 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/SKILL.md +41 -8
- package/skills/gsd-path-ship/references/reviewer-final.md +10 -0
- package/skills/gsd-path-ship/references/reviewer-wave.md +61 -22
- package/skills/gsd-path-ship/references/reviewer.md +8 -6
- package/skills/gsd-path-ship/references/runtime-selection.md +36 -0
- package/skills/gsd-path-ship/references/spec-probes.md +19 -1
- package/skills/gsd-path-ship/scripts/_common.py +76 -2
- package/skills/gsd-path-ship/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-ship/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-ship/scripts/build_state.py +333 -8
- package/skills/gsd-path-ship/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-ship/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-ship/scripts/integration.py +196 -39
- package/skills/gsd-path-ship/scripts/isolation.py +996 -38
- package/skills/gsd-path-ship/scripts/lean_verification.py +28 -13
- package/skills/gsd-path-ship/scripts/loop_run.py +9 -4
- package/skills/gsd-path-ship/scripts/members.py +17 -3
- package/skills/gsd-path-ship/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-ship/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/templates/wave-review.md +24 -8
- package/skills/gsd-path-undo/references/runtime-selection.md +36 -0
- package/skills/gsd-path-undo/scripts/_common.py +76 -2
- package/skills/gsd-path-undo/scripts/loop_run.py +9 -4
- package/skills/gsd-path-undo/scripts/worktree_paths.py +45 -0
- package/skills/path/BUILD.md +3 -1
- package/skills/path/DEFINE.md +2 -1
- package/skills/path/PLAN.md +10 -0
- package/skills/path/SHIP.md +41 -8
- package/skills/path/SKILL.md +16 -1
- package/skills/path/references/build-native.md +20 -3
- package/skills/path/references/coder.md +6 -3
- package/skills/path/references/reviewer-final.md +10 -0
- package/skills/path/references/reviewer-wave.md +61 -22
- package/skills/path/references/reviewer.md +8 -6
- package/skills/path/references/runtime-selection.md +36 -0
- package/skills/path/references/spec-probes.md +19 -1
- package/skills/path/scripts/_common.py +76 -2
- package/skills/path/scripts/archive_milestone.py +129 -7
- package/skills/path/scripts/build_recovery.py +25 -2
- package/skills/path/scripts/build_state.py +333 -8
- package/skills/path/scripts/check_handoffs.py +437 -30
- package/skills/path/scripts/check_task_briefs.py +135 -11
- package/skills/path/scripts/discussion_validate.py +46 -10
- package/skills/path/scripts/dispatch_driver.py +28 -3
- package/skills/path/scripts/git_guard.py +44 -4
- package/skills/path/scripts/integration.py +196 -39
- package/skills/path/scripts/isolation.py +996 -38
- package/skills/path/scripts/lean_verification.py +28 -13
- package/skills/path/scripts/loop_run.py +9 -4
- package/skills/path/scripts/members.py +17 -3
- package/skills/path/scripts/pipeline_state.py +10 -2
- package/skills/path/scripts/state_checkpoint.py +15 -0
- package/skills/path/scripts/status_runtime.py +73 -2
- package/skills/path/scripts/workflow_run.py +10 -3
- package/skills/path/scripts/worktree_paths.py +45 -0
- package/skills/path/templates/intent.md +5 -3
- package/skills/path/templates/task.md +8 -1
- package/skills/path/templates/wave-review.md +24 -8
package/scripts/isolation.py
CHANGED
|
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
13
|
import contextlib
|
|
14
|
-
import functools
|
|
15
14
|
import hashlib
|
|
16
15
|
import io
|
|
17
16
|
import json
|
|
@@ -27,7 +26,7 @@ import sys
|
|
|
27
26
|
sys.dont_write_bytecode = True
|
|
28
27
|
import tempfile
|
|
29
28
|
from pathlib import Path, PurePosixPath
|
|
30
|
-
from typing import Dict, Iterator, Optional, Sequence, Set, Tuple
|
|
29
|
+
from typing import Dict, Iterator, List, Optional, Sequence, Set, Tuple
|
|
31
30
|
|
|
32
31
|
if __package__:
|
|
33
32
|
from .pipeline_git import (
|
|
@@ -77,6 +76,8 @@ DISCUSSION_PATHS = frozenset(
|
|
|
77
76
|
VERIFY_LEDGER_PATH = _common.VERIFY_LEDGER_PATH
|
|
78
77
|
# recover verdicts that count as a landed task
|
|
79
78
|
LANDED_VERDICTS = frozenset({"recovered", "attested"})
|
|
79
|
+
# The withdrawn-tombstone citation rule is shared through _common (#483).
|
|
80
|
+
withdrawn_citations = _common.withdrawn_citations
|
|
80
81
|
# Orchestrator bookkeeping that may stay uncommitted in the primary while a
|
|
81
82
|
# parallel task lands; the next `.project` checkpoint commits it.
|
|
82
83
|
BOOKKEEPING_PATHS = DISCUSSION_PATHS | {
|
|
@@ -98,6 +99,13 @@ INLINE_LIST_PATTERN = _common.INLINE_LIST_PATTERN
|
|
|
98
99
|
LIST_ITEM_PATTERN = _common.LIST_ITEM_PATTERN
|
|
99
100
|
COLLECT_JOURNAL_SCHEMA = "gsd-path/collect-artifact/v1"
|
|
100
101
|
NULL_SHA = "0" * 40
|
|
102
|
+
RETIRE_HINT = (
|
|
103
|
+
" A stale isolate or sidecar from an earlier attempt may be there; retire it first:"
|
|
104
|
+
" `python3 -B <absolute isolation.py> retire --repo <absolute primary>"
|
|
105
|
+
" --worktree <path> --branch <branch> --force` (retire --force exists for exactly"
|
|
106
|
+
" this; a stale review sidecar must be hash-compared against its archived copy"
|
|
107
|
+
" before retiring)"
|
|
108
|
+
)
|
|
101
109
|
|
|
102
110
|
|
|
103
111
|
class IsolationError(RuntimeError):
|
|
@@ -174,8 +182,20 @@ def common_git_dir(repo: Path) -> Path:
|
|
|
174
182
|
|
|
175
183
|
|
|
176
184
|
def sidecar_root(primary: Path, kind: str, name: str, *, pin: bool = False) -> Path:
|
|
185
|
+
# Recovery resolves the same placements per task; each resolution lists
|
|
186
|
+
# every worktree. Within one read-only run the answer cannot change, so
|
|
187
|
+
# the entrypoint-cleared memo answers repeats (#469).
|
|
188
|
+
key = ("sidecar-root", str(primary), kind, name)
|
|
189
|
+
run_active = ("head-sha", str(primary)) in _RECOVERY_MEMO
|
|
177
190
|
try:
|
|
178
|
-
|
|
191
|
+
if run_active and not pin and key in _RECOVERY_MEMO:
|
|
192
|
+
return _RECOVERY_MEMO[key] # type: ignore[return-value]
|
|
193
|
+
resolved = worktree_paths.worktree_path(primary, kind, name, pin=pin)
|
|
194
|
+
if run_active and not pin:
|
|
195
|
+
# Only a read-only recovery run memoizes; pinning writes a
|
|
196
|
+
# placement receipt and must always run for real (#469).
|
|
197
|
+
_RECOVERY_MEMO[key] = resolved
|
|
198
|
+
return resolved
|
|
179
199
|
except (ValueError, OSError) as error:
|
|
180
200
|
raise IsolationError(str(error)) from error
|
|
181
201
|
|
|
@@ -500,6 +520,105 @@ def _long_path(absolute: str) -> str:
|
|
|
500
520
|
return "\\\\?\\" + absolute
|
|
501
521
|
|
|
502
522
|
|
|
523
|
+
def _is_link_like(status: os.stat_result) -> bool:
|
|
524
|
+
"""A symlink, or on Windows any name-surrogate reparse point such as a junction.
|
|
525
|
+
|
|
526
|
+
Path.is_symlink() is false for a junction, which still redirects the
|
|
527
|
+
path; the reparse name-surrogate bit covers junctions and symlinks on
|
|
528
|
+
every supported Python (3.9+) without os.path.isjunction (3.12+). The
|
|
529
|
+
same classification is used by guard_hook and detect_project.
|
|
530
|
+
"""
|
|
531
|
+
return stat.S_ISLNK(status.st_mode) or bool(
|
|
532
|
+
getattr(status, "st_reparse_tag", 0) & 0x20000000 # IO_REPARSE_TAG name surrogate
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _tracked_symlinks(tree: Path, when: str) -> Set[str]:
|
|
537
|
+
"""Paths, relative to `tree`, that its index (and submodules') tracks as symlinks."""
|
|
538
|
+
tracked_links: Set[str] = set()
|
|
539
|
+
if os.path.lexists(os.path.join(tree, ".git")):
|
|
540
|
+
listed = run_git(tree, "ls-files", "-s", "-z", "--recurse-submodules")
|
|
541
|
+
if listed.returncode != 0:
|
|
542
|
+
raise IsolationError(
|
|
543
|
+
f"could not list tracked files in {tree} {when}: "
|
|
544
|
+
+ (listed.stderr or listed.stdout).strip())
|
|
545
|
+
for record in listed.stdout.split("\0"):
|
|
546
|
+
info, _, path = record.partition("\t")
|
|
547
|
+
if info.startswith("120000 "):
|
|
548
|
+
tracked_links.add(path)
|
|
549
|
+
return tracked_links
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _unlink_link_like(tree: Path) -> None:
|
|
553
|
+
"""Unlink every link-like entry under a disposable tree before git removes it.
|
|
554
|
+
|
|
555
|
+
`git worktree remove` on Git for Windows older than 2.54 recurses through
|
|
556
|
+
NTFS junctions and deletes the target's contents (#462); untracked or
|
|
557
|
+
gitignored junctions pass the cleanliness checks that guard removal.
|
|
558
|
+
Unlinking each link — never traversing it — leaves git nothing to follow
|
|
559
|
+
and keeps POSIX symlinks on the same single code path. A link that cannot
|
|
560
|
+
be removed is a hard error: removal never proceeds with links present.
|
|
561
|
+
A symlink git tracks as a symlink is left for git, which removes it as an
|
|
562
|
+
index entry without traversing it; unlinking it would make a clean tree
|
|
563
|
+
dirty. A junction, or a symlink replacing a tracked non-link, is unlinked.
|
|
564
|
+
"""
|
|
565
|
+
remaining: List[str] = []
|
|
566
|
+
tracked_links = _tracked_symlinks(tree, "before removal")
|
|
567
|
+
|
|
568
|
+
def _unlink_one(entry: str) -> bool:
|
|
569
|
+
try:
|
|
570
|
+
os.unlink(entry)
|
|
571
|
+
return True
|
|
572
|
+
except OSError:
|
|
573
|
+
try:
|
|
574
|
+
os.rmdir(entry) # an empty link-like dir on some platforms
|
|
575
|
+
return True
|
|
576
|
+
except OSError:
|
|
577
|
+
remaining.append(entry)
|
|
578
|
+
return False
|
|
579
|
+
|
|
580
|
+
def _scan_failed(error: OSError) -> None:
|
|
581
|
+
# A directory that cannot be scanned leaves an unaudited subtree, and
|
|
582
|
+
# an incomplete sweep must never hand git a tree that may still hold
|
|
583
|
+
# a junction (#462).
|
|
584
|
+
raise IsolationError(f"could not scan {tree} before removal: {error}")
|
|
585
|
+
|
|
586
|
+
root = Path(_long_path(os.path.abspath(tree))) if os.name == "nt" else tree
|
|
587
|
+
|
|
588
|
+
def _spared(entry: str, status: os.stat_result) -> bool:
|
|
589
|
+
return stat.S_ISLNK(status.st_mode) and (
|
|
590
|
+
os.path.relpath(entry, root).replace(os.sep, "/") in tracked_links)
|
|
591
|
+
for directory, dirnames, _files in os.walk(root, topdown=True, onerror=_scan_failed):
|
|
592
|
+
for base in dirnames[:]:
|
|
593
|
+
entry = os.path.join(directory, base)
|
|
594
|
+
try:
|
|
595
|
+
status = os.lstat(entry)
|
|
596
|
+
except OSError as error:
|
|
597
|
+
_scan_failed(error)
|
|
598
|
+
return # unreachable; _scan_failed raises
|
|
599
|
+
if not _is_link_like(status):
|
|
600
|
+
continue
|
|
601
|
+
# Prune BEFORE unlinking: os.walk(followlinks=False) still
|
|
602
|
+
# descends Windows junctions — they are directories, not
|
|
603
|
+
# symlinks, at the stat level (CPython issue 88174) — so a
|
|
604
|
+
# link whose unlink fails must already be out of the walk.
|
|
605
|
+
dirnames.remove(base)
|
|
606
|
+
if not _spared(entry, status):
|
|
607
|
+
_unlink_one(entry)
|
|
608
|
+
for name in _files:
|
|
609
|
+
entry = os.path.join(directory, name)
|
|
610
|
+
try:
|
|
611
|
+
status = os.lstat(entry)
|
|
612
|
+
except OSError as error:
|
|
613
|
+
_scan_failed(error)
|
|
614
|
+
return # unreachable; _scan_failed raises
|
|
615
|
+
if _is_link_like(status) and not _spared(entry, status):
|
|
616
|
+
_unlink_one(entry)
|
|
617
|
+
if remaining:
|
|
618
|
+
raise IsolationError(
|
|
619
|
+
"could not unlink link-like entries before removal: " + ", ".join(remaining))
|
|
620
|
+
|
|
621
|
+
|
|
503
622
|
def _force_rmtree(path: Path) -> None:
|
|
504
623
|
"""Delete a sidecar tree directly, tolerating read-only files and long paths."""
|
|
505
624
|
def _writable(function, target, _error):
|
|
@@ -526,6 +645,10 @@ def _remove_worktree(primary: Path, destination: Path, force: bool) -> None:
|
|
|
526
645
|
"""
|
|
527
646
|
tree_delete_failure = ("failed to delete", "directory not empty", "permission denied")
|
|
528
647
|
|
|
648
|
+
if destination.is_dir():
|
|
649
|
+
# Defuse first: never let git traverse a junction or symlink (#462).
|
|
650
|
+
_unlink_link_like(destination)
|
|
651
|
+
|
|
529
652
|
def failure(*flags: str) -> str:
|
|
530
653
|
result = run_git(primary, "worktree", "remove", *flags, str(destination))
|
|
531
654
|
return (result.stderr or result.stdout).strip() if result.returncode else ""
|
|
@@ -567,6 +690,8 @@ def _cleanup_reserved_worktree(
|
|
|
567
690
|
records = _registered_worktrees(primary)
|
|
568
691
|
registered = records.get(destination.resolve())
|
|
569
692
|
if registered == ref and _same_directory(destination, reserved_directory):
|
|
693
|
+
if destination.is_dir():
|
|
694
|
+
_unlink_link_like(destination)
|
|
570
695
|
run_git(primary, "worktree", "remove", "--force", str(destination))
|
|
571
696
|
records = _registered_worktrees(primary)
|
|
572
697
|
elif registered is None and _same_directory(destination, reserved_directory):
|
|
@@ -588,7 +713,7 @@ def create_named_worktree(
|
|
|
588
713
|
f"refusing to check out the bound branch {bound} in a second worktree"
|
|
589
714
|
)
|
|
590
715
|
if os.path.lexists(destination):
|
|
591
|
-
raise IsolationError(f"worktree path already exists: {destination}")
|
|
716
|
+
raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT)
|
|
592
717
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
593
718
|
ref = f"refs/heads/{branch}"
|
|
594
719
|
reserved = run_git(primary, "update-ref", ref, base, NULL_SHA)
|
|
@@ -598,7 +723,7 @@ def create_named_worktree(
|
|
|
598
723
|
destination.mkdir()
|
|
599
724
|
except FileExistsError as error:
|
|
600
725
|
run_git(primary, "update-ref", "-d", ref, base)
|
|
601
|
-
raise IsolationError(f"worktree path already exists: {destination}") from error
|
|
726
|
+
raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT) from error
|
|
602
727
|
except OSError as error:
|
|
603
728
|
run_git(primary, "update-ref", "-d", ref, base)
|
|
604
729
|
raise IsolationError(f"cannot reserve worktree path {destination}: {error}") from error
|
|
@@ -752,7 +877,7 @@ def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
|
|
|
752
877
|
raise IsolationError(f"bound checkout is missing or invalid: {destination}")
|
|
753
878
|
if not holders:
|
|
754
879
|
if os.path.lexists(destination):
|
|
755
|
-
raise IsolationError(f"worktree path already exists: {destination}")
|
|
880
|
+
raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT)
|
|
756
881
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
757
882
|
added = run_git(checkout, "worktree", "add", str(destination), branch)
|
|
758
883
|
if added.returncode != 0:
|
|
@@ -760,6 +885,106 @@ def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
|
|
|
760
885
|
return {"branch": branch, "checkout": str(destination.resolve()), "member": member}
|
|
761
886
|
|
|
762
887
|
|
|
888
|
+
def _member_prepare_command(coordinator: Path, member: str) -> Optional[str]:
|
|
889
|
+
"""The member's declared Prepare command from MEMBERS.md, if any (#463)."""
|
|
890
|
+
try:
|
|
891
|
+
import members
|
|
892
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
893
|
+
from scripts import members
|
|
894
|
+
try:
|
|
895
|
+
rows = members.read_members(coordinator)
|
|
896
|
+
except members.MembersError as error:
|
|
897
|
+
raise IsolationError(str(error)) from error
|
|
898
|
+
for row in rows:
|
|
899
|
+
if row["name"] == member:
|
|
900
|
+
return row.get("prepare") or None
|
|
901
|
+
return None
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _refuse_link_like(tree: Path) -> None:
|
|
905
|
+
"""Refuse an isolate that provisioning left link-like entries in (#463).
|
|
906
|
+
|
|
907
|
+
Prepare must copy, never link: a link into the primary checkout would let
|
|
908
|
+
the isolate read and mutate shared inputs, and retirement would silently
|
|
909
|
+
defuse it (#462). Checked at handout, after the command exits zero. A
|
|
910
|
+
symlink the member repo tracks as a symlink is checked-out content, not
|
|
911
|
+
provisioning, and is spared; a junction, or a symlink replacing a tracked
|
|
912
|
+
non-link, is refused.
|
|
913
|
+
"""
|
|
914
|
+
found: List[str] = []
|
|
915
|
+
tracked_links = _tracked_symlinks(tree, "after Prepare")
|
|
916
|
+
root = Path(_long_path(os.path.abspath(tree))) if os.name == "nt" else tree
|
|
917
|
+
|
|
918
|
+
def _scan_failed(error: OSError) -> None:
|
|
919
|
+
raise IsolationError(f"could not scan {tree} after Prepare: {error}")
|
|
920
|
+
|
|
921
|
+
def _refused(entry: str, status: os.stat_result) -> bool:
|
|
922
|
+
return _is_link_like(status) and not (stat.S_ISLNK(status.st_mode) and (
|
|
923
|
+
os.path.relpath(entry, root).replace(os.sep, "/") in tracked_links))
|
|
924
|
+
|
|
925
|
+
for directory, dirnames, files in os.walk(root, topdown=True, onerror=_scan_failed):
|
|
926
|
+
for base in dirnames[:]:
|
|
927
|
+
entry = os.path.join(directory, base)
|
|
928
|
+
try:
|
|
929
|
+
status = os.lstat(entry)
|
|
930
|
+
except OSError as error:
|
|
931
|
+
_scan_failed(error)
|
|
932
|
+
return
|
|
933
|
+
if _is_link_like(status):
|
|
934
|
+
dirnames.remove(base) # never descend a link
|
|
935
|
+
if _refused(entry, status):
|
|
936
|
+
found.append(entry)
|
|
937
|
+
for name in files:
|
|
938
|
+
entry = os.path.join(directory, name)
|
|
939
|
+
try:
|
|
940
|
+
status = os.lstat(entry)
|
|
941
|
+
except OSError as error:
|
|
942
|
+
_scan_failed(error)
|
|
943
|
+
return
|
|
944
|
+
if _refused(entry, status):
|
|
945
|
+
found.append(entry)
|
|
946
|
+
if found:
|
|
947
|
+
raise IsolationError(
|
|
948
|
+
"member Prepare left link-like entries; provisioning must copy, "
|
|
949
|
+
"never link: " + ", ".join(found))
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def run_member_prepare(coordinator: Path, member: str, sidecar: Path) -> Optional[Dict[str, object]]:
|
|
953
|
+
"""Run the member's declared Prepare command inside a fresh isolate (#463).
|
|
954
|
+
|
|
955
|
+
A bare member worktree lacks uninitialized submodules and gitignored build
|
|
956
|
+
inputs; the committed MEMBERS.md `Prepare:` command provisions them before
|
|
957
|
+
any Verify runs. The command runs with the isolate as cwd, through the
|
|
958
|
+
same bash argv Verify uses (host-neutral, and a script file on Windows);
|
|
959
|
+
a non-zero exit is a loud IsolationError carrying the output so the
|
|
960
|
+
attempt fails before a Verify burns time against a half-provisioned
|
|
961
|
+
tree. Provisioning must copy, never link, outside the isolate (#462) —
|
|
962
|
+
retirement defuses links, and a link would let the isolate mutate shared
|
|
963
|
+
inputs. Retained isolates keep their provisioning (#461): this runs only
|
|
964
|
+
where a fresh isolate is created.
|
|
965
|
+
"""
|
|
966
|
+
command = _member_prepare_command(coordinator, member)
|
|
967
|
+
if command is None:
|
|
968
|
+
return None
|
|
969
|
+
completed = subprocess.run(
|
|
970
|
+
_common.bash_argv(command), cwd=sidecar,
|
|
971
|
+
capture_output=True, encoding="utf-8", errors="replace",
|
|
972
|
+
)
|
|
973
|
+
receipt = {
|
|
974
|
+
"command": command,
|
|
975
|
+
"exit_code": completed.returncode,
|
|
976
|
+
"stdout": completed.stdout,
|
|
977
|
+
"stderr": completed.stderr,
|
|
978
|
+
}
|
|
979
|
+
if completed.returncode != 0:
|
|
980
|
+
raise IsolationError(
|
|
981
|
+
f"member {member} Prepare failed in {sidecar}: "
|
|
982
|
+
f"{completed.stderr.strip() or completed.stdout.strip() or completed.returncode}"
|
|
983
|
+
)
|
|
984
|
+
_refuse_link_like(sidecar)
|
|
985
|
+
return receipt
|
|
986
|
+
|
|
987
|
+
|
|
763
988
|
def isolate_member_task(coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
|
|
764
989
|
"""Member tasks always run in a sidecar at the member bound branch tip."""
|
|
765
990
|
validate_task_id(task_id)
|
|
@@ -776,7 +1001,22 @@ def isolate_member_task(coordinator: Path, member: str, task_id: str) -> Dict[st
|
|
|
776
1001
|
branch = f"{TASK_BRANCH_PREFIX}{name}"
|
|
777
1002
|
destination = sidecar_root(checkout, "task", name, pin=True)
|
|
778
1003
|
create_named_worktree(checkout, branch, destination, member_base, bound=str(bound["branch"]))
|
|
1004
|
+
try:
|
|
1005
|
+
prepared = run_member_prepare(coordinator, member, destination)
|
|
1006
|
+
except BaseException as error:
|
|
1007
|
+
# A fresh tree with a failed Prepare has nothing to land; retire it so
|
|
1008
|
+
# the retry starts clean instead of refusing on the existing path.
|
|
1009
|
+
try:
|
|
1010
|
+
_remove_worktree(checkout, destination, force=True)
|
|
1011
|
+
run_git(checkout, "update-ref", "-d", f"refs/heads/{branch}", member_base)
|
|
1012
|
+
except IsolationError as cleanup_error:
|
|
1013
|
+
raise IsolationError(
|
|
1014
|
+
f"{error}; retiring the fresh isolate also failed: {cleanup_error}") from error
|
|
1015
|
+
finally:
|
|
1016
|
+
release_workspace_quietly(checkout)
|
|
1017
|
+
raise
|
|
779
1018
|
return {
|
|
1019
|
+
"prepare": prepared,
|
|
780
1020
|
"bound_branch": bound["branch"],
|
|
781
1021
|
"bound_checkout": bound["checkout"],
|
|
782
1022
|
"kind": "task",
|
|
@@ -1021,6 +1261,15 @@ def activate_member_task(
|
|
|
1021
1261
|
elif not copy.is_file():
|
|
1022
1262
|
copy.parent.mkdir(parents=True, exist_ok=True)
|
|
1023
1263
|
_common.atomic_write(copy, activated)
|
|
1264
|
+
# Pin the sibling preflight inputs (#484): the coder's role requires the
|
|
1265
|
+
# Interface-contract-sharing sibling task files; a member sidecar receives
|
|
1266
|
+
# them from the immutable coordinator base beside the live task copy.
|
|
1267
|
+
for sibling_file in _member_sibling_contract_files(coordinator, resolved_base, task_file):
|
|
1268
|
+
sibling_copy = _safe_member_task_copy(sidecar, sibling_file)
|
|
1269
|
+
sibling_copy.parent.mkdir(parents=True, exist_ok=True)
|
|
1270
|
+
_common.atomic_write(
|
|
1271
|
+
sibling_copy, git_text(coordinator, "show", f"{resolved_base}:{sibling_file}")
|
|
1272
|
+
)
|
|
1024
1273
|
ref = member_task_authorization_ref(project, task_id)
|
|
1025
1274
|
created = run_git(checkout, "update-ref", ref, member_base, "0" * 40)
|
|
1026
1275
|
if created.returncode != 0 and run_git(checkout, "rev-parse", "--verify", "--quiet", ref).stdout.strip() != member_base:
|
|
@@ -1032,6 +1281,137 @@ def activate_member_task(
|
|
|
1032
1281
|
return result
|
|
1033
1282
|
|
|
1034
1283
|
|
|
1284
|
+
def reauthorize_member_task(coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
|
|
1285
|
+
"""Re-arm the member task authorization a failed finish cleared (#461).
|
|
1286
|
+
|
|
1287
|
+
A failed finish clears the authorization ref so that attempt cannot land.
|
|
1288
|
+
When the failure was environmental, the sidecar still stands at the same
|
|
1289
|
+
member base with the same live copy, and this explicit command re-records
|
|
1290
|
+
the authorization at that base so a rerunning finish can land without a
|
|
1291
|
+
full re-isolate. The copy is coder-writable, so every binding comes from
|
|
1292
|
+
orchestrator-owned records instead: the dispatch ledger's native member
|
|
1293
|
+
activation names the agent, base, member base, task file, and branch the
|
|
1294
|
+
live copy must still match, the coordinator task must still be pending,
|
|
1295
|
+
the contract at the recorded base must be unchanged with an append-only
|
|
1296
|
+
Log, the member base must stand unchanged and unlanded, and no member
|
|
1297
|
+
landing journal may be pending. Nothing inside the failed attempt's own
|
|
1298
|
+
machinery invokes this; it is an owner decision, under the driver lock.
|
|
1299
|
+
"""
|
|
1300
|
+
coordinator = require_directory(coordinator, "coordinator")
|
|
1301
|
+
try:
|
|
1302
|
+
if __package__:
|
|
1303
|
+
from . import dispatch_driver
|
|
1304
|
+
else:
|
|
1305
|
+
import dispatch_driver
|
|
1306
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
1307
|
+
from scripts import dispatch_driver
|
|
1308
|
+
try:
|
|
1309
|
+
lock = dispatch_driver.acquire_lock(coordinator)
|
|
1310
|
+
except dispatch_driver.DriverStop as error:
|
|
1311
|
+
raise IsolationError(str(error)) from error
|
|
1312
|
+
with lock:
|
|
1313
|
+
return _reauthorize_member_task_locked(dispatch_driver, coordinator, member, task_id)
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _reauthorize_member_task_locked(
|
|
1317
|
+
dispatch_driver, coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
|
|
1318
|
+
checkout, project, entry = _member_context(coordinator, member)
|
|
1319
|
+
task_id = validate_task_id(task_id)
|
|
1320
|
+
# The orchestrator-recorded activation is the source of truth for every
|
|
1321
|
+
# binding; the live copy only proves continuity against it.
|
|
1322
|
+
record = None
|
|
1323
|
+
for state in dispatch_driver.latest_states(dispatch_driver.records_root(coordinator)):
|
|
1324
|
+
if (state.get("task_id") == task_id and state.get("mode") == "member"
|
|
1325
|
+
and state.get("member") == member):
|
|
1326
|
+
record = state
|
|
1327
|
+
break
|
|
1328
|
+
if record is None:
|
|
1329
|
+
raise IsolationError(f"member task {task_id} has no recorded member activation")
|
|
1330
|
+
branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
|
|
1331
|
+
if record.get("task_branch") not in (None, branch):
|
|
1332
|
+
raise IsolationError(f"member task {task_id} activation names another task branch")
|
|
1333
|
+
recorded_member_base = require_full_sha(str(record["member_base"]))
|
|
1334
|
+
recorded_base = require_full_sha(str(record["base"]))
|
|
1335
|
+
# The native record's task_file names the sidecar copy path (including
|
|
1336
|
+
# the copy-directory prefix); the contract file is the coordinator path.
|
|
1337
|
+
task_file = str(record.get("contract_file") or record["task_file"])
|
|
1338
|
+
if task_file.startswith(MEMBER_TASK_COPY_DIR + "/"):
|
|
1339
|
+
task_file = task_file[len(MEMBER_TASK_COPY_DIR) + 1:]
|
|
1340
|
+
sidecar = sidecar_root(checkout, "task", f"{project}-{task_id}")
|
|
1341
|
+
if require_attached(sidecar) != branch:
|
|
1342
|
+
raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
|
|
1343
|
+
copy = _safe_member_task_copy(sidecar, task_file)
|
|
1344
|
+
if not copy.is_file():
|
|
1345
|
+
raise IsolationError(f"member task {task_id} live copy is missing: {copy}")
|
|
1346
|
+
copy_text = copy.read_text(encoding="utf-8")
|
|
1347
|
+
fields, error = task_frontmatter(copy_text)
|
|
1348
|
+
if error or fields is None or fields.get("id") != task_id:
|
|
1349
|
+
raise IsolationError(error or "unreadable member task live copy")
|
|
1350
|
+
if fields.get("status") != "in-progress":
|
|
1351
|
+
raise IsolationError(f"member task {task_id} live copy is not in-progress")
|
|
1352
|
+
agent = fields.get("agent")
|
|
1353
|
+
if not agent:
|
|
1354
|
+
raise IsolationError(f"member task {task_id} live copy lacks its agent")
|
|
1355
|
+
recorded_agent = record.get("agent")
|
|
1356
|
+
if recorded_agent and agent != recorded_agent:
|
|
1357
|
+
raise IsolationError(
|
|
1358
|
+
f"member task {task_id} live copy activation fields differ from its "
|
|
1359
|
+
"recorded activation; re-isolate instead")
|
|
1360
|
+
if not recorded_agent:
|
|
1361
|
+
# Activations recorded before the agent field existed name no agent;
|
|
1362
|
+
# the field is informational today (no authorization decision
|
|
1363
|
+
# consumes it), so a legacy record accepts the copy's non-empty one.
|
|
1364
|
+
pass
|
|
1365
|
+
if (fields.get("base") != recorded_base
|
|
1366
|
+
or fields.get("member_base") != recorded_member_base):
|
|
1367
|
+
raise IsolationError(
|
|
1368
|
+
f"member task {task_id} live copy activation fields differ from its "
|
|
1369
|
+
"recorded activation; re-isolate instead")
|
|
1370
|
+
# A settled coordinator task has nothing left to authorize.
|
|
1371
|
+
coordinator_task = coordinator / task_file
|
|
1372
|
+
coordinator_fields, coordinator_error = task_frontmatter(
|
|
1373
|
+
coordinator_task.read_text(encoding="utf-8"))
|
|
1374
|
+
if coordinator_error or coordinator_fields is None:
|
|
1375
|
+
raise IsolationError(coordinator_error or f"unreadable coordinator task: {task_file}")
|
|
1376
|
+
if coordinator_fields.get("status") != "pending":
|
|
1377
|
+
raise IsolationError(
|
|
1378
|
+
f"member task {task_id} is {coordinator_fields.get('status')} in the "
|
|
1379
|
+
"coordinator; there is nothing to reauthorize")
|
|
1380
|
+
member_base = recorded_member_base
|
|
1381
|
+
# The coder's uncommitted work may sit in the sidecar; the task branch and
|
|
1382
|
+
# the member bound branch must still stand at the recorded member base,
|
|
1383
|
+
# and nothing may have landed yet.
|
|
1384
|
+
tip = run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").stdout.strip()
|
|
1385
|
+
if tip != member_base:
|
|
1386
|
+
raise IsolationError(f"member task branch {branch} moved off its member base")
|
|
1387
|
+
if run_git(checkout, "merge-base", "--is-ancestor", member_base,
|
|
1388
|
+
f"refs/heads/{entry['branch']}").returncode != 0:
|
|
1389
|
+
raise IsolationError("member task member base is not on the member bound branch")
|
|
1390
|
+
resolved_base = require_commit(coordinator, recorded_base)
|
|
1391
|
+
_member_contract(coordinator, resolved_base, task_file, member)
|
|
1392
|
+
contract = git_text(coordinator, "show", f"{resolved_base}:{task_file}")
|
|
1393
|
+
member_log_delta(contract, copy_text)
|
|
1394
|
+
path = _member_journal_path(coordinator, task_id)
|
|
1395
|
+
if os.path.lexists(path):
|
|
1396
|
+
raise IsolationError(
|
|
1397
|
+
f"a member landing is pending for {task_id}; run recover_member_landing first")
|
|
1398
|
+
for pending in sorted(path.parent.glob("*.json")):
|
|
1399
|
+
if json.loads(pending.read_text(encoding="utf-8"))["member"] == member:
|
|
1400
|
+
raise IsolationError(
|
|
1401
|
+
f"member {member} has pending landing {pending.stem}; run recover_member_landing first")
|
|
1402
|
+
ref = member_task_authorization_ref(project, task_id)
|
|
1403
|
+
recorded = run_git(checkout, "rev-parse", "--verify", "--quiet", ref).stdout.strip()
|
|
1404
|
+
if recorded and recorded != member_base:
|
|
1405
|
+
raise IsolationError("member task authorization names another member base")
|
|
1406
|
+
if not recorded:
|
|
1407
|
+
created = run_git(checkout, "update-ref", ref, member_base, "0" * 40)
|
|
1408
|
+
if created.returncode != 0:
|
|
1409
|
+
raise IsolationError("could not record member task authorization")
|
|
1410
|
+
return {"agent": agent, "base": resolved_base, "copy": str(copy), "member": member,
|
|
1411
|
+
"member_base": member_base, "status": "in-progress", "task_branch": branch,
|
|
1412
|
+
"task_file": task_file, "worktree": str(sidecar), "reauthorized": not recorded}
|
|
1413
|
+
|
|
1414
|
+
|
|
1035
1415
|
def retained_member_task(
|
|
1036
1416
|
coordinator: Path, member: str, task_id: str, task_file: str, base: str
|
|
1037
1417
|
) -> Optional[Dict[str, object]]:
|
|
@@ -1164,6 +1544,36 @@ def _record_member_journal(path: Path, journal: Dict[str, object]) -> None:
|
|
|
1164
1544
|
_common.atomic_write(path, json.dumps(journal, indent=2, sort_keys=True) + "\n")
|
|
1165
1545
|
|
|
1166
1546
|
|
|
1547
|
+
def _member_sibling_contract_files(
|
|
1548
|
+
coordinator: Path, base: str, task_file: str
|
|
1549
|
+
) -> List[str]:
|
|
1550
|
+
"""Sibling task files sharing this task's Interface contract, at the base.
|
|
1551
|
+
|
|
1552
|
+
The coder's sibling-interface preflight needs each sibling's task file;
|
|
1553
|
+
a member activation projects only the live task, so the preflight had no
|
|
1554
|
+
sanctioned read (#484). Siblings are recognized by their Interface
|
|
1555
|
+
contract text: every other task at `base` that shares at least one
|
|
1556
|
+
Interface-contract bullet with this task. The copies land beside the live task under the
|
|
1557
|
+
member copy directory, read from the immutable `base` tree.
|
|
1558
|
+
"""
|
|
1559
|
+
def contract_bullets(text: str) -> Set[str]:
|
|
1560
|
+
body = _common.section_body(text, "Interface contract")
|
|
1561
|
+
return set() if body is None else _common.contract_shapes(body)
|
|
1562
|
+
|
|
1563
|
+
live = contract_bullets(git_text(coordinator, "show", f"{base}:{task_file}"))
|
|
1564
|
+
if not live:
|
|
1565
|
+
return []
|
|
1566
|
+
listing = git_output(
|
|
1567
|
+
coordinator, "ls-tree", "-r", "--name-only", base, "--", ".project/tasks")
|
|
1568
|
+
siblings: List[str] = []
|
|
1569
|
+
for sibling_file in listing.splitlines():
|
|
1570
|
+
if not sibling_file.endswith(".md") or sibling_file == task_file:
|
|
1571
|
+
continue
|
|
1572
|
+
if contract_bullets(git_text(coordinator, "show", f"{base}:{sibling_file}")) & live:
|
|
1573
|
+
siblings.append(sibling_file)
|
|
1574
|
+
return siblings
|
|
1575
|
+
|
|
1576
|
+
|
|
1167
1577
|
def _member_contract(
|
|
1168
1578
|
coordinator: Path, base: str, task_file: str, member: str,
|
|
1169
1579
|
current_text: Optional[str] = None,
|
|
@@ -1468,8 +1878,11 @@ def isolate_member_verify(coordinator: Path, sidecar: Path) -> Dict[str, Dict[st
|
|
|
1468
1878
|
branch = f"{VERIFY_BRANCH_PREFIX}{project}-{entry['name']}"
|
|
1469
1879
|
destination = sidecar.parent / entry["name"]
|
|
1470
1880
|
create_named_worktree(checkout, branch, destination, tip, bound=entry["branch"])
|
|
1881
|
+
# Registered before Prepare so a failed provisioning retires with
|
|
1882
|
+
# the loop's existing cleanup (#463).
|
|
1471
1883
|
created[entry["name"]] = {"checkout": str(checkout), "worktree": str(destination),
|
|
1472
1884
|
"branch": branch, "tip": tip}
|
|
1885
|
+
run_member_prepare(coordinator, entry["name"], destination)
|
|
1473
1886
|
except BaseException:
|
|
1474
1887
|
retire_member_verify(created)
|
|
1475
1888
|
raise
|
|
@@ -1545,7 +1958,7 @@ def ensure_verify_sidecar(primary: Path, base: str, branch: str) -> Dict[str, ob
|
|
|
1545
1958
|
ref = f"refs/heads/{branch}"
|
|
1546
1959
|
registered = _registered_worktrees(primary)
|
|
1547
1960
|
if destination.is_symlink() or (destination.exists() and not destination.is_dir()):
|
|
1548
|
-
raise IsolationError(f"worktree path already exists: {destination}")
|
|
1961
|
+
raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT)
|
|
1549
1962
|
if destination.is_dir():
|
|
1550
1963
|
try:
|
|
1551
1964
|
# A leftover empty directory (a half-deleted sidecar) clears;
|
|
@@ -1578,6 +1991,8 @@ def ensure_verify_sidecar(primary: Path, base: str, branch: str) -> Dict[str, ob
|
|
|
1578
1991
|
"worktree": str(destination),
|
|
1579
1992
|
}
|
|
1580
1993
|
if destination.resolve() in registered:
|
|
1994
|
+
if destination.is_dir():
|
|
1995
|
+
_unlink_link_like(destination)
|
|
1581
1996
|
cleared = run_git(primary, "worktree", "remove", str(destination))
|
|
1582
1997
|
if cleared.returncode != 0:
|
|
1583
1998
|
run_git(primary, "worktree", "prune")
|
|
@@ -1748,6 +2163,16 @@ def _commit_pending(
|
|
|
1748
2163
|
return current_sha(repo)
|
|
1749
2164
|
|
|
1750
2165
|
|
|
2166
|
+
# Run-scoped memo for read-only recovery queries (#469): recovery re-proves
|
|
2167
|
+
# every task against the same immutable commits, so shared-base lookups (tree
|
|
2168
|
+
# kinds, blob listings, serial bookkeeping, the primary worktree report) answer
|
|
2169
|
+
# once per run. Each public recovery entrypoint clears it, so a process that
|
|
2170
|
+
# mutates between runs never reads a stale entry, and only immutable inputs —
|
|
2171
|
+
# full commit shas or per-run constants — are ever keyed.
|
|
2172
|
+
_RECOVERY_MEMO: Dict[tuple, object] = {}
|
|
2173
|
+
_FULL_SHA = re.compile(r"[0-9a-f]{40}")
|
|
2174
|
+
|
|
2175
|
+
|
|
1751
2176
|
def undeclared_paths(
|
|
1752
2177
|
repo: Path, base: str, paths: Set[str], declared: Set[str]
|
|
1753
2178
|
) -> list[str]:
|
|
@@ -1757,11 +2182,17 @@ def undeclared_paths(
|
|
|
1757
2182
|
An entry that is a file at `base` covers only itself: the task-brief lint
|
|
1758
2183
|
rejects a path below a base file.
|
|
1759
2184
|
"""
|
|
2185
|
+
cacheable = _FULL_SHA.fullmatch(base) is not None
|
|
1760
2186
|
|
|
1761
|
-
@functools.lru_cache(maxsize=None)
|
|
1762
2187
|
def directory(entry: str) -> bool:
|
|
2188
|
+
key = ("tree-kind", str(repo), base, entry)
|
|
2189
|
+
if cacheable and key in _RECOVERY_MEMO:
|
|
2190
|
+
return _RECOVERY_MEMO[key] # type: ignore[index]
|
|
1763
2191
|
kind = run_git(repo, "cat-file", "-t", f"{base}:{entry}")
|
|
1764
|
-
|
|
2192
|
+
covering = kind.returncode != 0 or kind.stdout.strip() == "tree"
|
|
2193
|
+
if cacheable:
|
|
2194
|
+
_RECOVERY_MEMO[key] = covering
|
|
2195
|
+
return covering
|
|
1765
2196
|
|
|
1766
2197
|
return sorted(
|
|
1767
2198
|
path
|
|
@@ -1774,6 +2205,124 @@ def undeclared_paths(
|
|
|
1774
2205
|
)
|
|
1775
2206
|
|
|
1776
2207
|
|
|
2208
|
+
RUNTIME_HOTFIX_MARKER = " — runtime-hotfix: "
|
|
2209
|
+
RUNTIME_PIN = ".gsd-path/runtime.json"
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def _runtime_hotfix_receipts(task_text: str, task_file: str) -> List[Dict[str, str]]:
|
|
2213
|
+
"""The runtime-hotfix receipts recorded in a task Log (#480).
|
|
2214
|
+
|
|
2215
|
+
Each receipt line is
|
|
2216
|
+
`- <date> — runtime-hotfix: task <id> base <sha> runtime <old> -> <new> authorized by <ruling>`.
|
|
2217
|
+
The orchestrator's own command writes it; a hand-written line without
|
|
2218
|
+
matching digests authorizes nothing.
|
|
2219
|
+
"""
|
|
2220
|
+
receipts: List[Dict[str, str]] = []
|
|
2221
|
+
for line in task_text.splitlines():
|
|
2222
|
+
stripped = line.strip()
|
|
2223
|
+
if not stripped.startswith("- ") or RUNTIME_HOTFIX_MARKER not in stripped:
|
|
2224
|
+
continue
|
|
2225
|
+
match = re.fullmatch(
|
|
2226
|
+
r"- (?P<date>\d{4}-\d{2}-\d{2}) — runtime-hotfix: task (?P<task>T\d{3}) "
|
|
2227
|
+
r"base (?P<base>[0-9a-f]{40}) runtime (?P<old>[0-9a-f]{16,64}) "
|
|
2228
|
+
r"-> (?P<new>[0-9a-f]{16,64}) authorized by (?P<authority>.+)",
|
|
2229
|
+
stripped,
|
|
2230
|
+
)
|
|
2231
|
+
if match is not None:
|
|
2232
|
+
receipts.append(match.groupdict())
|
|
2233
|
+
return receipts
|
|
2234
|
+
|
|
2235
|
+
|
|
2236
|
+
def runtime_hotfix_ref(task_id: str, base: str) -> str:
|
|
2237
|
+
"""The orchestrator-owned ref recording a runtime-hotfix receipt (#480).
|
|
2238
|
+
|
|
2239
|
+
The task Log is coder-writable, so a Log line alone proves nothing: the
|
|
2240
|
+
runtime-hotfix command also stores the receipt as a blob behind this ref,
|
|
2241
|
+
and refs/gsd-path/ is guard-owned — an agent cannot write it. A landing
|
|
2242
|
+
admits the pin path only when the Log line and the ref record match
|
|
2243
|
+
verbatim.
|
|
2244
|
+
"""
|
|
2245
|
+
return f"refs/gsd-path/runtime-hotfix/{task_id}-{base}"
|
|
2246
|
+
|
|
2247
|
+
|
|
2248
|
+
RUNTIME_SCHEMA_VALUE = "gsd-path/runtime/v1"
|
|
2249
|
+
|
|
2250
|
+
|
|
2251
|
+
def runtime_hotfix_allowed_paths(
|
|
2252
|
+
repo: Path, task_text: str, task_file: str, base: str,
|
|
2253
|
+
pin_text: Optional[str] = None,
|
|
2254
|
+
) -> Set[str]:
|
|
2255
|
+
"""The runtime pin path a task's receipts authorize at this base (#480).
|
|
2256
|
+
|
|
2257
|
+
Authorization needs BOTH records: the receipt line in the task Log —
|
|
2258
|
+
matched against the task's own frontmatter id and this landing's base —
|
|
2259
|
+
and the same line stored verbatim behind the orchestrator-owned
|
|
2260
|
+
runtime-hotfix ref. A Log line without the ref is a bare mention and
|
|
2261
|
+
authorizes nothing; a receipt for any other task or base authorizes
|
|
2262
|
+
nothing.
|
|
2263
|
+
"""
|
|
2264
|
+
fields, error = task_frontmatter(task_text)
|
|
2265
|
+
if error or fields is None:
|
|
2266
|
+
return set()
|
|
2267
|
+
task_id = str(fields.get("id", ""))
|
|
2268
|
+
if not task_id:
|
|
2269
|
+
return set()
|
|
2270
|
+
ref = runtime_hotfix_ref(task_id, base)
|
|
2271
|
+
recorded = run_git(repo, "cat-file", "blob", ref)
|
|
2272
|
+
if recorded.returncode != 0:
|
|
2273
|
+
return set()
|
|
2274
|
+
recorded_lines = recorded.stdout.splitlines()
|
|
2275
|
+
# The exact pin bytes under admission — from the working tree, the index,
|
|
2276
|
+
# or a commit — supplied by the caller that knows which state it gates.
|
|
2277
|
+
if pin_text is None:
|
|
2278
|
+
try:
|
|
2279
|
+
pin_text = (repo / RUNTIME_PIN).read_text(encoding="utf-8")
|
|
2280
|
+
except OSError:
|
|
2281
|
+
return set()
|
|
2282
|
+
try:
|
|
2283
|
+
landed_pin = json.loads(pin_text)
|
|
2284
|
+
except json.JSONDecodeError:
|
|
2285
|
+
return set()
|
|
2286
|
+
if not isinstance(landed_pin, dict):
|
|
2287
|
+
return set()
|
|
2288
|
+
allowed: Set[str] = set()
|
|
2289
|
+
for receipt in _runtime_hotfix_receipts(task_text, task_file):
|
|
2290
|
+
if (receipt["task"] == task_id and receipt["base"] == base
|
|
2291
|
+
and receipt_line(receipt) in recorded_lines
|
|
2292
|
+
and _pin_is_receipt_runtime(landed_pin, receipt["new"])):
|
|
2293
|
+
allowed.add(RUNTIME_PIN)
|
|
2294
|
+
return allowed
|
|
2295
|
+
|
|
2296
|
+
|
|
2297
|
+
def _pin_is_receipt_runtime(pin: Dict[str, object], digest: str) -> bool:
|
|
2298
|
+
"""Whether a pin declaration names exactly the receipt's replacement runtime.
|
|
2299
|
+
|
|
2300
|
+
Digest alone is not enough: the guard's loader needs schema and version
|
|
2301
|
+
too, so a receipt must not admit a declaration those checks would refuse.
|
|
2302
|
+
"""
|
|
2303
|
+
if pin.get("digest") != digest or str(pin.get("schema", "")) != RUNTIME_SCHEMA_VALUE:
|
|
2304
|
+
return False
|
|
2305
|
+
try:
|
|
2306
|
+
import status_runtime
|
|
2307
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
2308
|
+
from scripts import status_runtime
|
|
2309
|
+
manifest_path = status_runtime.runtime_home() / digest / "manifest.json"
|
|
2310
|
+
try:
|
|
2311
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
2312
|
+
except (OSError, json.JSONDecodeError):
|
|
2313
|
+
return False
|
|
2314
|
+
return pin.get("version") == manifest.get("version")
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
def receipt_line(receipt: Dict[str, str]) -> str:
|
|
2318
|
+
"""The canonical Log text of a parsed receipt, without its list marker."""
|
|
2319
|
+
return (
|
|
2320
|
+
f"{receipt['date']} — runtime-hotfix: task {receipt['task']} base "
|
|
2321
|
+
f"{receipt['base']} runtime {receipt['old']} -> {receipt['new']} "
|
|
2322
|
+
f"authorized by {receipt['authority']}"
|
|
2323
|
+
)
|
|
2324
|
+
|
|
2325
|
+
|
|
1777
2326
|
def validate_allowed_changes(
|
|
1778
2327
|
repo: Path,
|
|
1779
2328
|
base: str,
|
|
@@ -2046,6 +2595,160 @@ def _write_native_member_dispatch_record(coordinator: Path, activation: Dict[str
|
|
|
2046
2595
|
dispatch_driver.record_native_member_activation(coordinator, activation)
|
|
2047
2596
|
|
|
2048
2597
|
|
|
2598
|
+
def _hotfix_snapshot_tree(primary: Path) -> str:
|
|
2599
|
+
"""Tree of the primary's complete working tree via a temporary index."""
|
|
2600
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
2601
|
+
env = dict(os.environ, GIT_INDEX_FILE=str(Path(temporary) / "index"))
|
|
2602
|
+
for arguments in (("read-tree", "HEAD"), ("add", "-A"), ("write-tree",)):
|
|
2603
|
+
completed = subprocess.run(("git", "-C", str(primary), *arguments), env=env,
|
|
2604
|
+
encoding="utf-8", errors="replace", capture_output=True)
|
|
2605
|
+
if completed.returncode:
|
|
2606
|
+
raise IsolationError(
|
|
2607
|
+
f"git {' '.join(arguments)} failed: {completed.stderr.strip()}")
|
|
2608
|
+
return completed.stdout.strip()
|
|
2609
|
+
|
|
2610
|
+
|
|
2611
|
+
def _hotfix_task_file(primary: Path, task_id: str) -> str:
|
|
2612
|
+
tasks_dir = primary / ".project" / "tasks"
|
|
2613
|
+
matches = sorted(tasks_dir.glob(f"{task_id}-*.md")) if tasks_dir.is_dir() else []
|
|
2614
|
+
exact = tasks_dir / f"{task_id}.md"
|
|
2615
|
+
if exact.is_file() and not exact.is_symlink():
|
|
2616
|
+
matches = [exact]
|
|
2617
|
+
if len(matches) != 1 or not matches[0].is_file() or matches[0].is_symlink():
|
|
2618
|
+
raise IsolationError(f"task file for {task_id} is missing or ambiguous")
|
|
2619
|
+
return matches[0].relative_to(primary).as_posix()
|
|
2620
|
+
|
|
2621
|
+
|
|
2622
|
+
def _append_task_log(primary: Path, task_file: str, line: str) -> None:
|
|
2623
|
+
path = primary / task_file
|
|
2624
|
+
text = path.read_text(encoding="utf-8")
|
|
2625
|
+
if not text.endswith("\n"):
|
|
2626
|
+
text += "\n"
|
|
2627
|
+
_common.atomic_write(path, text + line + "\n")
|
|
2628
|
+
|
|
2629
|
+
|
|
2630
|
+
def runtime_hotfix(
|
|
2631
|
+
primary: Path,
|
|
2632
|
+
task_id: str,
|
|
2633
|
+
runtime_digest: str,
|
|
2634
|
+
authority: str,
|
|
2635
|
+
) -> Dict[str, object]:
|
|
2636
|
+
"""Authorize a runtime hotfix for one active serial task and re-verify it (#480).
|
|
2637
|
+
|
|
2638
|
+
The replacement runtime must be published in the shared store; the
|
|
2639
|
+
authorization, the exact task/base/digest identities, and the re-run
|
|
2640
|
+
Verify all stay explicit, and no unrelated change may slip into the
|
|
2641
|
+
landing. This command validates the replacement runtime, re-runs the
|
|
2642
|
+
task's Verify in a fresh verify sidecar at the recorded base, and on
|
|
2643
|
+
success records the receipt twice: as a blob behind the guard-owned
|
|
2644
|
+
runtime-hotfix ref (the authority an agent cannot forge) and as a Log
|
|
2645
|
+
line in the task file (the provenance a reader sees). Only a Log line
|
|
2646
|
+
whose identical twin the ref holds later admits `.gsd-path/runtime.json`
|
|
2647
|
+
into that task's landing; a failing re-run refuses the hotfix before
|
|
2648
|
+
either record is written.
|
|
2649
|
+
"""
|
|
2650
|
+
primary = require_directory(primary, "primary worktree")
|
|
2651
|
+
task_id = validate_task_id(task_id)
|
|
2652
|
+
authority = authority.strip()
|
|
2653
|
+
if not authority or authority.splitlines() != [authority]:
|
|
2654
|
+
raise IsolationError("runtime-hotfix requires the authorizing ruling as one line of text")
|
|
2655
|
+
try:
|
|
2656
|
+
import runtime_store
|
|
2657
|
+
import status_runtime
|
|
2658
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
2659
|
+
from scripts import runtime_store, status_runtime
|
|
2660
|
+
from datetime import date as _date
|
|
2661
|
+
task_file = _hotfix_task_file(primary, task_id)
|
|
2662
|
+
task_text, _ = _read_task_text(primary / task_file)
|
|
2663
|
+
fields, error = task_frontmatter(task_text)
|
|
2664
|
+
if error or fields is None:
|
|
2665
|
+
raise IsolationError(error or f"unreadable task frontmatter: {task_file}")
|
|
2666
|
+
if fields.get("status") != "in-progress":
|
|
2667
|
+
raise IsolationError(
|
|
2668
|
+
f"runtime-hotfix applies to an active task; {task_id} is {fields.get('status')}")
|
|
2669
|
+
if str(fields.get("worktree", "")) not in ("", "null") and Path(
|
|
2670
|
+
str(fields.get("worktree"))).resolve() != primary.resolve():
|
|
2671
|
+
raise IsolationError(
|
|
2672
|
+
"runtime-hotfix applies to a serial task working in the primary; "
|
|
2673
|
+
f"{task_id} records another worktree")
|
|
2674
|
+
recorded_base = fields.get("base")
|
|
2675
|
+
if not isinstance(recorded_base, str) or not re.fullmatch(r"[0-9a-f]{40}", recorded_base):
|
|
2676
|
+
raise IsolationError(f"{task_id} records no full base; the hotfix cannot be bound")
|
|
2677
|
+
command = _common.task_verify_command(task_text)
|
|
2678
|
+
if not command:
|
|
2679
|
+
raise IsolationError(f"{task_id} has no Verify command to re-run")
|
|
2680
|
+
new_digest = runtime_digest.strip().lower()
|
|
2681
|
+
if re.fullmatch(r"[0-9a-f]{64}", new_digest) is None:
|
|
2682
|
+
raise IsolationError("--runtime must be the 64-hex digest of a published runtime")
|
|
2683
|
+
manifest_path = status_runtime.runtime_home() / new_digest / "manifest.json"
|
|
2684
|
+
try:
|
|
2685
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
2686
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
2687
|
+
raise IsolationError(
|
|
2688
|
+
f"runtime {new_digest} is not published in the store: {error}"
|
|
2689
|
+
) from error
|
|
2690
|
+
try:
|
|
2691
|
+
status_runtime.validate_runtime({
|
|
2692
|
+
"schema": status_runtime.RUNTIME_SCHEMA,
|
|
2693
|
+
"version": str(manifest.get("version", "")),
|
|
2694
|
+
"digest": new_digest,
|
|
2695
|
+
})
|
|
2696
|
+
declaration = status_runtime.declaration(primary)
|
|
2697
|
+
except (OSError, ValueError) as error:
|
|
2698
|
+
raise IsolationError(f"runtime-hotfix refused: {error}") from error
|
|
2699
|
+
old_digest = str(declaration.get("digest", ""))
|
|
2700
|
+
if old_digest == new_digest:
|
|
2701
|
+
raise IsolationError("runtime-hotfix requires a different runtime than the pinned one")
|
|
2702
|
+
sidecar = ensure_verify_sidecar(
|
|
2703
|
+
primary, recorded_base, verify_branch_name(f"runtime-hotfix-{task_id}")
|
|
2704
|
+
)
|
|
2705
|
+
sidecar_path = Path(str(sidecar["worktree"]))
|
|
2706
|
+
reproduce_verify_sidecar(sidecar_path, _hotfix_snapshot_tree(primary))
|
|
2707
|
+
# The re-run must prove the task under the REPLACEMENT runtime: write the
|
|
2708
|
+
# new declaration into the sidecar's pin before Verify runs (#480). The
|
|
2709
|
+
# primary's pin stays untouched; sidecar effects are the point of the
|
|
2710
|
+
# sidecar.
|
|
2711
|
+
sidecar_pin = sidecar_path / RUNTIME_PIN
|
|
2712
|
+
sidecar_pin.parent.mkdir(parents=True, exist_ok=True)
|
|
2713
|
+
_common.atomic_write(sidecar_pin, json.dumps({
|
|
2714
|
+
"schema": status_runtime.RUNTIME_SCHEMA,
|
|
2715
|
+
"version": str(manifest.get("version", "")),
|
|
2716
|
+
"digest": new_digest,
|
|
2717
|
+
}, indent=2, sort_keys=True) + "\n")
|
|
2718
|
+
completed = subprocess.run(
|
|
2719
|
+
("sh", "-c", command), cwd=str(sidecar_path),
|
|
2720
|
+
encoding="utf-8", errors="replace", capture_output=True,
|
|
2721
|
+
)
|
|
2722
|
+
clean_verify(primary, sidecar_path, recorded_base, str(sidecar["branch"]))
|
|
2723
|
+
retire(primary, sidecar_path, str(sidecar["branch"]), False)
|
|
2724
|
+
if completed.returncode != 0:
|
|
2725
|
+
raise IsolationError(
|
|
2726
|
+
f"{task_id} Verify failed in the hotfix sidecar; the hotfix is refused")
|
|
2727
|
+
receipt = (
|
|
2728
|
+
f"- {_date.today().isoformat()} — runtime-hotfix: task {task_id} "
|
|
2729
|
+
f"base {recorded_base} runtime {old_digest} -> {new_digest} "
|
|
2730
|
+
f"authorized by {authority}"
|
|
2731
|
+
)
|
|
2732
|
+
blob = run_git(primary, "hash-object", "-w", "--stdin", input=receipt[2:])
|
|
2733
|
+
if blob.returncode != 0:
|
|
2734
|
+
raise IsolationError("runtime-hotfix could not record its receipt blob")
|
|
2735
|
+
created = run_git(
|
|
2736
|
+
primary, "update-ref", runtime_hotfix_ref(task_id, recorded_base),
|
|
2737
|
+
blob.stdout.strip(),
|
|
2738
|
+
)
|
|
2739
|
+
if created.returncode != 0:
|
|
2740
|
+
raise IsolationError("runtime-hotfix could not record its authorization ref")
|
|
2741
|
+
_append_task_log(primary, task_file, receipt)
|
|
2742
|
+
return {
|
|
2743
|
+
"task": task_id,
|
|
2744
|
+
"base": recorded_base,
|
|
2745
|
+
"old_runtime": old_digest,
|
|
2746
|
+
"new_runtime": new_digest,
|
|
2747
|
+
"receipt": receipt,
|
|
2748
|
+
"authorization_ref": runtime_hotfix_ref(task_id, recorded_base),
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
|
|
2049
2752
|
def deactivate_task(
|
|
2050
2753
|
worktree: Path, task_id: str, task_branch: str
|
|
2051
2754
|
) -> Dict[str, object]:
|
|
@@ -2275,7 +2978,10 @@ def _stamp_and_commit(
|
|
|
2275
2978
|
pending = uncommitted_paths(worktree)
|
|
2276
2979
|
if stamped != task_bytes:
|
|
2277
2980
|
pending.add(task_file)
|
|
2278
|
-
|
|
2981
|
+
# A runtime hotfix receipt for exactly this task and base admits the
|
|
2982
|
+
# runtime pin path into this landing — nothing else does (#480).
|
|
2983
|
+
hotfix_allowed = runtime_hotfix_allowed_paths(worktree, task_text, task_file, base)
|
|
2984
|
+
validate_allowed_changes(worktree, base, pending, allowed | hotfix_allowed, tolerated)
|
|
2279
2985
|
original_head = current_sha(worktree)
|
|
2280
2986
|
index_tree = git_output(worktree, "write-tree")
|
|
2281
2987
|
try:
|
|
@@ -2348,6 +3054,11 @@ def land(
|
|
|
2348
3054
|
allowed = {relative_posix(task_file)}
|
|
2349
3055
|
for path in allow_paths:
|
|
2350
3056
|
allowed.add(relative_posix(path))
|
|
3057
|
+
# A runtime-hotfix receipt for exactly this task and base admits the
|
|
3058
|
+
# runtime pin path into this landing — nothing else does (#480).
|
|
3059
|
+
task_text, _ = _read_task_text(primary / relative_posix(task_file))
|
|
3060
|
+
allowed |= runtime_hotfix_allowed_paths(
|
|
3061
|
+
primary, task_text, relative_posix(task_file), resolved_base)
|
|
2351
3062
|
serial = source == primary
|
|
2352
3063
|
if serial:
|
|
2353
3064
|
if source_branch != bound:
|
|
@@ -2552,13 +3263,52 @@ def _declared_task_paths(
|
|
|
2552
3263
|
return None, str(error)
|
|
2553
3264
|
|
|
2554
3265
|
|
|
3266
|
+
def _tree_blob_map(repo: Path, revision: str) -> Dict[str, tuple[str, str, str]]:
|
|
3267
|
+
"""{path: (mode, type, oid)} for one commit's full tree."""
|
|
3268
|
+
mapping: Dict[str, tuple[str, str, str]] = {}
|
|
3269
|
+
for entry in git_output(
|
|
3270
|
+
repo, "ls-tree", "-r", "-z", revision
|
|
3271
|
+
).split("\0"):
|
|
3272
|
+
if not entry:
|
|
3273
|
+
continue
|
|
3274
|
+
meta, separator, name = entry.partition("\t")
|
|
3275
|
+
if not separator:
|
|
3276
|
+
continue
|
|
3277
|
+
parts = meta.split()
|
|
3278
|
+
if len(parts) == 3:
|
|
3279
|
+
mapping[name] = (parts[0], parts[1], parts[2])
|
|
3280
|
+
return mapping
|
|
3281
|
+
|
|
3282
|
+
|
|
2555
3283
|
def _regular_blob_oid(repo: Path, revision: str, path: str) -> str:
|
|
3284
|
+
# A full-sha revision addresses immutable content: the listing answers
|
|
3285
|
+
# once per run (#469). Moving names are never cached, but within one
|
|
3286
|
+
# read-only run HEAD is the entrypoint's recorded head, so resolve it.
|
|
3287
|
+
if revision == "HEAD":
|
|
3288
|
+
recorded = _RECOVERY_MEMO.get(("head-sha", str(repo)))
|
|
3289
|
+
if isinstance(recorded, str):
|
|
3290
|
+
# Every task compares against the same run head: one full listing
|
|
3291
|
+
# answers all of them (#469).
|
|
3292
|
+
map_key = ("head-tree", str(repo), recorded)
|
|
3293
|
+
if map_key not in _RECOVERY_MEMO:
|
|
3294
|
+
_RECOVERY_MEMO[map_key] = _tree_blob_map(repo, recorded)
|
|
3295
|
+
mapping = _RECOVERY_MEMO[map_key]
|
|
3296
|
+
assert isinstance(mapping, dict)
|
|
3297
|
+
entry = mapping.get(path)
|
|
3298
|
+
if entry is None or entry[1] != "blob" or entry[0] not in {"100644", "100755"}:
|
|
3299
|
+
raise IsolationError(f"task path is not a regular file in HEAD: {path}")
|
|
3300
|
+
return entry[2]
|
|
3301
|
+
key = ("blob-oid", str(repo), revision, path)
|
|
3302
|
+
if _FULL_SHA.fullmatch(revision) is not None and key in _RECOVERY_MEMO:
|
|
3303
|
+
return str(_RECOVERY_MEMO[key])
|
|
2556
3304
|
entries = git_output(repo, "ls-tree", revision, "--", path).splitlines()
|
|
2557
3305
|
if len(entries) != 1:
|
|
2558
3306
|
raise IsolationError(f"task path is not a regular file in {revision}: {path}")
|
|
2559
3307
|
parts = entries[0].split(maxsplit=3)
|
|
2560
3308
|
if len(parts) != 4 or parts[0] not in {"100644", "100755"} or parts[1] != "blob":
|
|
2561
3309
|
raise IsolationError(f"task path is not a regular file in {revision}: {path}")
|
|
3310
|
+
if _FULL_SHA.fullmatch(revision) is not None:
|
|
3311
|
+
_RECOVERY_MEMO[key] = parts[2]
|
|
2562
3312
|
return parts[2]
|
|
2563
3313
|
|
|
2564
3314
|
|
|
@@ -2619,9 +3369,12 @@ def _landing_state(
|
|
|
2619
3369
|
contract_paths,
|
|
2620
3370
|
f"source commit subject is {subject!r}, expected {expected_subject!r}",
|
|
2621
3371
|
)
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
3372
|
+
# The runtime pin path joins only with a matching hotfix receipt (#480).
|
|
3373
|
+
receipt_allowed = runtime_hotfix_allowed_paths(repo, after_text, task_file, base)
|
|
3374
|
+
comparable = allowed - receipt_allowed if allowed is not None else None
|
|
3375
|
+
if comparable is not None and comparable != contract_paths:
|
|
3376
|
+
missing = sorted(contract_paths - comparable)
|
|
3377
|
+
extra = sorted(comparable - contract_paths)
|
|
2625
3378
|
details = []
|
|
2626
3379
|
if missing:
|
|
2627
3380
|
details.append("missing " + ", ".join(missing))
|
|
@@ -2700,7 +3453,14 @@ def _prove_task_commit(
|
|
|
2700
3453
|
)
|
|
2701
3454
|
if task_file not in changed:
|
|
2702
3455
|
return None, "commit does not touch the task file"
|
|
2703
|
-
|
|
3456
|
+
committed_pin = None
|
|
3457
|
+
if RUNTIME_PIN in changed:
|
|
3458
|
+
shown = run_git(repo, "show", f"{sha}:{RUNTIME_PIN}")
|
|
3459
|
+
committed_pin = shown.stdout if shown.returncode == 0 else ""
|
|
3460
|
+
stray = undeclared_paths(
|
|
3461
|
+
repo, base, changed,
|
|
3462
|
+
contract_paths | runtime_hotfix_allowed_paths(
|
|
3463
|
+
repo, after_text, task_file, base, pin_text=committed_pin))
|
|
2704
3464
|
if stray:
|
|
2705
3465
|
return None, f"commit touches undeclared paths: {', '.join(stray)}"
|
|
2706
3466
|
if landing_error:
|
|
@@ -2868,6 +3628,23 @@ def _worktree_report(
|
|
|
2868
3628
|
return None, f"invalid sidecar worktree {path}: {error}"
|
|
2869
3629
|
|
|
2870
3630
|
|
|
3631
|
+
def _primary_worktree_report(
|
|
3632
|
+
primary: Path, bound: str, head: str
|
|
3633
|
+
) -> tuple[Optional[Dict[str, object]], Optional[str]]:
|
|
3634
|
+
"""The primary's own worktree report, answered once per recovery run.
|
|
3635
|
+
|
|
3636
|
+
Recovery is read-only and the run's head is fixed, so every serial task
|
|
3637
|
+
sees the same answer; the entrypoint-cleared memo scopes it to one run
|
|
3638
|
+
(#469).
|
|
3639
|
+
"""
|
|
3640
|
+
key = ("primary-report", str(primary), bound, head)
|
|
3641
|
+
if key in _RECOVERY_MEMO:
|
|
3642
|
+
return _RECOVERY_MEMO[key] # type: ignore[return-value]
|
|
3643
|
+
report = _worktree_report(primary, primary, bound)
|
|
3644
|
+
_RECOVERY_MEMO[key] = report
|
|
3645
|
+
return report
|
|
3646
|
+
|
|
3647
|
+
|
|
2871
3648
|
def _retained_task_contract_error(
|
|
2872
3649
|
current_text: str, isolate_text: str
|
|
2873
3650
|
) -> Optional[str]:
|
|
@@ -2932,6 +3709,15 @@ def _serial_bookkeeping_error(primary: Path, base: str, head: str) -> Optional[s
|
|
|
2932
3709
|
"""Prove that serial task history advanced only through linear .project commits."""
|
|
2933
3710
|
if base == head:
|
|
2934
3711
|
return None
|
|
3712
|
+
key = ("serial-bookkeeping", str(primary), base, head)
|
|
3713
|
+
if key in _RECOVERY_MEMO:
|
|
3714
|
+
return _RECOVERY_MEMO[key] # type: ignore[return-value]
|
|
3715
|
+
error = _serial_bookkeeping_proof(primary, base, head)
|
|
3716
|
+
_RECOVERY_MEMO[key] = error
|
|
3717
|
+
return error
|
|
3718
|
+
|
|
3719
|
+
|
|
3720
|
+
def _serial_bookkeeping_proof(primary: Path, base: str, head: str) -> Optional[str]:
|
|
2935
3721
|
if run_git(primary, "merge-base", "--is-ancestor", base, head).returncode != 0:
|
|
2936
3722
|
return "recorded base is not an ancestor of the current branch"
|
|
2937
3723
|
commits = git_output(
|
|
@@ -3104,6 +3890,18 @@ def _recover_task(
|
|
|
3104
3890
|
return result("block", reason=str(error))
|
|
3105
3891
|
except IsolationError as exc:
|
|
3106
3892
|
return result("block", reason=str(exc))
|
|
3893
|
+
if status == "withdrawn":
|
|
3894
|
+
# A cited tombstone is inventory, not recoverable work: no isolate,
|
|
3895
|
+
# no landing, nothing to resume — coordinator or member alike (#483).
|
|
3896
|
+
# Without the owner citation the tombstone is an obligation someone
|
|
3897
|
+
# dropped: block it.
|
|
3898
|
+
if not withdrawn_citations(task_text):
|
|
3899
|
+
return result(
|
|
3900
|
+
"block",
|
|
3901
|
+
reason="withdrawn task lacks a `- Withdrawn — <YYYY-MM-DD>: "
|
|
3902
|
+
"<owner ruling verbatim>` Log citation",
|
|
3903
|
+
)
|
|
3904
|
+
return result("none", reason="withdrawn task is a tombstone")
|
|
3107
3905
|
if fields.get("repo"):
|
|
3108
3906
|
# Only the landing record proves member task completion.
|
|
3109
3907
|
if status in ("pending", "in-progress"):
|
|
@@ -3132,7 +3930,7 @@ def _recover_task(
|
|
|
3132
3930
|
return leftover(landed["commit"], landed["base"])
|
|
3133
3931
|
return result("block", reason=worktree_error)
|
|
3134
3932
|
|
|
3135
|
-
valid_statuses = {"pending", "in-progress", "done", "failed", "blocked"}
|
|
3933
|
+
valid_statuses = {"pending", "in-progress", "done", "failed", "blocked", "withdrawn"}
|
|
3136
3934
|
if status not in valid_statuses:
|
|
3137
3935
|
return result("block", reason=f"unknown task status: {status!r}")
|
|
3138
3936
|
|
|
@@ -3271,7 +4069,7 @@ def _recover_task(
|
|
|
3271
4069
|
"block",
|
|
3272
4070
|
reason=f"{status} serial task advanced past its base: {advance_error}",
|
|
3273
4071
|
)
|
|
3274
|
-
primary_worktree, primary_error =
|
|
4072
|
+
primary_worktree, primary_error = _primary_worktree_report(primary, bound, head)
|
|
3275
4073
|
if primary_error:
|
|
3276
4074
|
return result("block", reason=primary_error)
|
|
3277
4075
|
report["worktree"] = primary_worktree
|
|
@@ -3327,11 +4125,14 @@ def _recover_task(
|
|
|
3327
4125
|
"block",
|
|
3328
4126
|
reason=f"{status} serial task advanced past its base: {advance_error}",
|
|
3329
4127
|
)
|
|
3330
|
-
primary_worktree, primary_error =
|
|
4128
|
+
primary_worktree, primary_error = _primary_worktree_report(primary, bound, head)
|
|
3331
4129
|
if primary_error:
|
|
3332
4130
|
return result("block", reason=primary_error)
|
|
3333
4131
|
report["worktree"] = primary_worktree
|
|
3334
|
-
return result(
|
|
4132
|
+
return result(
|
|
4133
|
+
"reconcile", base=base,
|
|
4134
|
+
reason=f"{status} task needs recovery; run reconcile for its classification",
|
|
4135
|
+
)
|
|
3335
4136
|
|
|
3336
4137
|
recorded_base = str(fields.get("base", ""))
|
|
3337
4138
|
try:
|
|
@@ -3414,9 +4215,7 @@ def _recover_task(
|
|
|
3414
4215
|
primary, recorded_base, task_file, task_text, subject
|
|
3415
4216
|
)
|
|
3416
4217
|
if retry_error is None:
|
|
3417
|
-
primary_worktree, primary_error =
|
|
3418
|
-
primary, primary, bound
|
|
3419
|
-
)
|
|
4218
|
+
primary_worktree, primary_error = _primary_worktree_report(primary, bound, head)
|
|
3420
4219
|
if primary_error:
|
|
3421
4220
|
return result("block", reason=primary_error)
|
|
3422
4221
|
report["worktree"] = primary_worktree
|
|
@@ -3521,6 +4320,20 @@ def recover(primary: Path, tasks_dir: Path) -> Dict[str, object]:
|
|
|
3521
4320
|
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
3522
4321
|
bound = require_attached(primary)
|
|
3523
4322
|
head = current_sha(primary)
|
|
4323
|
+
_RECOVERY_MEMO.clear()
|
|
4324
|
+
_RECOVERY_MEMO[("head-sha", str(primary))] = head
|
|
4325
|
+
try:
|
|
4326
|
+
with worktree_paths.readonly_placement_cache():
|
|
4327
|
+
return _recover_all(primary, tasks_dir, bound, head)
|
|
4328
|
+
finally:
|
|
4329
|
+
# HEAD resumes its moving-name meaning the moment the read-only run
|
|
4330
|
+
# ends: landing flows in the same process must never resolve it to a
|
|
4331
|
+
# stale recorded head.
|
|
4332
|
+
_RECOVERY_MEMO.pop(("head-sha", str(primary)), None)
|
|
4333
|
+
_RECOVERY_MEMO.pop(("head-tree", str(primary), head), None)
|
|
4334
|
+
|
|
4335
|
+
|
|
4336
|
+
def _recover_all(primary: Path, tasks_dir: Path, bound: str, head: str) -> Dict[str, object]:
|
|
3524
4337
|
task_paths, inventory_error = _recovery_task_inventory(primary, tasks_dir, head)
|
|
3525
4338
|
if inventory_error:
|
|
3526
4339
|
return {
|
|
@@ -3534,10 +4347,11 @@ def recover(primary: Path, tasks_dir: Path) -> Dict[str, object]:
|
|
|
3534
4347
|
branches = set(
|
|
3535
4348
|
git_output(primary, "for-each-ref", "--format=%(refname:short)", f"refs/heads/{TASK_BRANCH_PREFIX}").splitlines()
|
|
3536
4349
|
)
|
|
3537
|
-
tasks = [
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
4350
|
+
tasks = []
|
|
4351
|
+
for number, path in enumerate(task_paths, start=1):
|
|
4352
|
+
if number % 20 == 0:
|
|
4353
|
+
print(f"recover: {number}/{len(task_paths)} tasks", file=sys.stderr, flush=True)
|
|
4354
|
+
tasks.append(_recover_task(primary, path, history, branches, head, bound))
|
|
3541
4355
|
return {
|
|
3542
4356
|
"bound_branch": bound,
|
|
3543
4357
|
"tasks": tasks,
|
|
@@ -3558,7 +4372,6 @@ def verify_landed_task_files(
|
|
|
3558
4372
|
raise IsolationError(f"primary is not its Git root: {primary}")
|
|
3559
4373
|
bound = require_attached(primary)
|
|
3560
4374
|
resolved_head = require_commit(primary, require_full_sha(head))
|
|
3561
|
-
canonical_dir = relative_posix(canonical_tasks_dir)
|
|
3562
4375
|
history = _first_parent_history(primary, resolved_head)
|
|
3563
4376
|
branches = set(
|
|
3564
4377
|
git_output(
|
|
@@ -3568,8 +4381,32 @@ def verify_landed_task_files(
|
|
|
3568
4381
|
f"refs/heads/{TASK_BRANCH_PREFIX}",
|
|
3569
4382
|
).splitlines()
|
|
3570
4383
|
)
|
|
4384
|
+
_RECOVERY_MEMO.clear()
|
|
4385
|
+
_RECOVERY_MEMO[("head-sha", str(primary))] = resolved_head
|
|
4386
|
+
try:
|
|
4387
|
+
with worktree_paths.readonly_placement_cache():
|
|
4388
|
+
return _verify_landed_all(
|
|
4389
|
+
primary, list(task_paths), relative_posix(canonical_tasks_dir),
|
|
4390
|
+
resolved_head, history, branches, bound,
|
|
4391
|
+
)
|
|
4392
|
+
finally:
|
|
4393
|
+
_RECOVERY_MEMO.pop(("head-sha", str(primary)), None)
|
|
4394
|
+
_RECOVERY_MEMO.pop(("head-tree", str(primary), resolved_head), None)
|
|
4395
|
+
|
|
4396
|
+
|
|
4397
|
+
def _verify_landed_all(
|
|
4398
|
+
primary: Path,
|
|
4399
|
+
task_paths: Sequence[Path],
|
|
4400
|
+
canonical_dir: str,
|
|
4401
|
+
resolved_head: str,
|
|
4402
|
+
history: Dict[str, list[tuple[str, str]]],
|
|
4403
|
+
branches: Set[str],
|
|
4404
|
+
bound: str,
|
|
4405
|
+
) -> Dict[str, object]:
|
|
3571
4406
|
tasks = []
|
|
3572
|
-
for path in task_paths:
|
|
4407
|
+
for number, path in enumerate(task_paths, start=1):
|
|
4408
|
+
if number % 20 == 0:
|
|
4409
|
+
print(f"recover: {number}/{len(task_paths)} tasks", file=sys.stderr, flush=True)
|
|
3573
4410
|
resolved = path.resolve()
|
|
3574
4411
|
if path.is_symlink() or not path.is_file():
|
|
3575
4412
|
raise IsolationError(f"task artifact is missing or unsafe: {path}")
|
|
@@ -3582,6 +4419,22 @@ def verify_landed_task_files(
|
|
|
3582
4419
|
fields, error = task_frontmatter(task_text)
|
|
3583
4420
|
if error or fields is None:
|
|
3584
4421
|
raise IsolationError(error or f"unreadable task frontmatter: {path.name}")
|
|
4422
|
+
if fields.get("status") == "withdrawn":
|
|
4423
|
+
# A cited tombstone is shippable inventory with no landing to
|
|
4424
|
+
# prove; an uncited one is an obligation someone dropped (#483).
|
|
4425
|
+
if not withdrawn_citations(task_text):
|
|
4426
|
+
raise IsolationError(
|
|
4427
|
+
f"{fields.get('id', path.name)}: withdrawn task needs a "
|
|
4428
|
+
"`- Withdrawn — <YYYY-MM-DD>: <owner ruling verbatim>` Log citation"
|
|
4429
|
+
)
|
|
4430
|
+
tasks.append({
|
|
4431
|
+
"task": canonical,
|
|
4432
|
+
"task_id": str(fields.get("id", "")),
|
|
4433
|
+
"status": "withdrawn",
|
|
4434
|
+
"verdict": "withdrawn",
|
|
4435
|
+
"reason": "withdrawn task is a tombstone",
|
|
4436
|
+
})
|
|
4437
|
+
continue
|
|
3585
4438
|
if fields.get("status") != "done":
|
|
3586
4439
|
raise IsolationError(
|
|
3587
4440
|
f"{fields.get('id', path.name)}: task must have status: done"
|
|
@@ -3597,7 +4450,10 @@ def verify_landed_task_files(
|
|
|
3597
4450
|
canonical,
|
|
3598
4451
|
)
|
|
3599
4452
|
)
|
|
3600
|
-
blocked = [
|
|
4453
|
+
blocked = [
|
|
4454
|
+
task for task in tasks
|
|
4455
|
+
if task["verdict"] not in LANDED_VERDICTS and task["verdict"] != "withdrawn"
|
|
4456
|
+
]
|
|
3601
4457
|
if blocked:
|
|
3602
4458
|
def failure_reason(task: Dict[str, object]) -> str:
|
|
3603
4459
|
rejected = task.get("rejected")
|
|
@@ -4516,6 +5372,50 @@ def _committed_ship_active(repo: Path) -> bool:
|
|
|
4516
5372
|
return fields.get("phase") == "ship" and fields.get("status") == "active"
|
|
4517
5373
|
|
|
4518
5374
|
|
|
5375
|
+
# The STATE Log tail that sanctions a build checkpoint past a HEAD that is
|
|
5376
|
+
# still ship/active: the blocked final review went through the patch plan
|
|
5377
|
+
# (reopened, then approved) and build started again (#526).
|
|
5378
|
+
_PATCH_REENTRY_LOG = (
|
|
5379
|
+
("plan", "patch plan reopened"),
|
|
5380
|
+
("plan", "patch plan approved"),
|
|
5381
|
+
("build", "build started"),
|
|
5382
|
+
)
|
|
5383
|
+
|
|
5384
|
+
|
|
5385
|
+
def _patch_reentry_build_active(repo: Path) -> bool:
|
|
5386
|
+
"""True when the worktree STATE is the sanctioned patch-re-entry build.
|
|
5387
|
+
|
|
5388
|
+
A blocked final review leaves HEAD at `build: complete milestone`
|
|
5389
|
+
(ship/active) on purpose: the patch plan runs uncommitted and the patch
|
|
5390
|
+
build's own checkpoint commits the artifacts (SHIP.md). This reads the
|
|
5391
|
+
worktree STATE the checkpoint is about to commit and accepts only the
|
|
5392
|
+
documented sequence — a hand-flipped build/active during final review
|
|
5393
|
+
carries no such Log tail and stays refused (#429, #526).
|
|
5394
|
+
"""
|
|
5395
|
+
|
|
5396
|
+
state = repo / ".project" / "STATE.md"
|
|
5397
|
+
try:
|
|
5398
|
+
text = state.read_text(encoding="utf-8")
|
|
5399
|
+
except (OSError, UnicodeDecodeError):
|
|
5400
|
+
return False
|
|
5401
|
+
frontmatter = text.split("\n---", 1)[0]
|
|
5402
|
+
fields = {
|
|
5403
|
+
key: _unquote(value)
|
|
5404
|
+
for key, value in re.findall(r"(?m)^(phase|status):(.*)$", frontmatter)
|
|
5405
|
+
}
|
|
5406
|
+
if not (fields.get("phase") == "build" and fields.get("status") == "active"):
|
|
5407
|
+
return False
|
|
5408
|
+
events: List[Tuple[str, str]] = []
|
|
5409
|
+
for line in text.splitlines():
|
|
5410
|
+
match = re.fullmatch(r"- \d{4}-\d{2}-\d{2} — (\S+) — (.+)", line)
|
|
5411
|
+
if match:
|
|
5412
|
+
events.append((match.group(1), match.group(2)))
|
|
5413
|
+
# The sanctioned sequence appears in order, as the Log tail of this
|
|
5414
|
+
# milestone's patch loop; the final event must be the build restart.
|
|
5415
|
+
last = len(_PATCH_REENTRY_LOG)
|
|
5416
|
+
return len(events) >= last and tuple(events[-last:]) == _PATCH_REENTRY_LOG
|
|
5417
|
+
|
|
5418
|
+
|
|
4519
5419
|
def checkpoint(
|
|
4520
5420
|
repo: Path,
|
|
4521
5421
|
expected_head: str,
|
|
@@ -4565,7 +5465,11 @@ def checkpoint(
|
|
|
4565
5465
|
"subject": subject.strip(),
|
|
4566
5466
|
}
|
|
4567
5467
|
raise IsolationError("primary HEAD differs from --expected-head")
|
|
4568
|
-
if
|
|
5468
|
+
if (
|
|
5469
|
+
subject.strip().startswith("build:")
|
|
5470
|
+
and _committed_ship_active(repo)
|
|
5471
|
+
and not _patch_reentry_build_active(repo)
|
|
5472
|
+
):
|
|
4569
5473
|
raise IsolationError(
|
|
4570
5474
|
"STATE is ship/active: final review creates no pre-ship commit; leave its "
|
|
4571
5475
|
"outputs uncommitted for archive_milestone.py, and send findings through "
|
|
@@ -4600,10 +5504,18 @@ def checkpoint(
|
|
|
4600
5504
|
)
|
|
4601
5505
|
if unexpected:
|
|
4602
5506
|
raise IsolationError("unexpected paths: " + ", ".join(unexpected))
|
|
5507
|
+
# An unsupported entry violates only if it will exist after this
|
|
5508
|
+
# checkpoint commits: _commit_pending stages exactly the pending paths
|
|
5509
|
+
# from the worktree, so a staged or completed removal (git rm / git mv)
|
|
5510
|
+
# passes and the removal lands here instead of forcing a plain commit
|
|
5511
|
+
# (#466). A kept worktree file (rm --cached, untracked junk) would be
|
|
5512
|
+
# re-staged and still blocks.
|
|
4603
5513
|
unsupported = sorted({
|
|
4604
5514
|
path.split("/")[1]
|
|
4605
5515
|
for path in pending
|
|
4606
|
-
if path.startswith(".project/")
|
|
5516
|
+
if path.startswith(".project/")
|
|
5517
|
+
and path.split("/")[1] not in PROJECT_ENTRIES
|
|
5518
|
+
and ((repo / path).exists() or (repo / path).is_symlink())
|
|
4607
5519
|
})
|
|
4608
5520
|
if unsupported:
|
|
4609
5521
|
raise IsolationError(
|
|
@@ -4935,9 +5847,16 @@ def parser() -> argparse.ArgumentParser:
|
|
|
4935
5847
|
activate_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
|
|
4936
5848
|
activate_member_parser.add_argument("--member", required=True)
|
|
4937
5849
|
activate_member_parser.add_argument("--task-id", required=True)
|
|
4938
|
-
activate_member_parser.add_argument("--agent"
|
|
4939
|
-
activate_member_parser.add_argument("--task-file"
|
|
4940
|
-
activate_member_parser.add_argument("--base"
|
|
5850
|
+
activate_member_parser.add_argument("--agent")
|
|
5851
|
+
activate_member_parser.add_argument("--task-file")
|
|
5852
|
+
activate_member_parser.add_argument("--base")
|
|
5853
|
+
activate_member_parser.add_argument(
|
|
5854
|
+
"--reauthorize", action="store_true",
|
|
5855
|
+
help="re-record the authorization a failed finish cleared, for the same "
|
|
5856
|
+
"live copy at the same member base, without re-isolating (#461); "
|
|
5857
|
+
"--task-file/--base come from the recorded activation, which the "
|
|
5858
|
+
"live copy must match"
|
|
5859
|
+
)
|
|
4941
5860
|
|
|
4942
5861
|
retire_member_parser = subparsers.add_parser(
|
|
4943
5862
|
"retire-member-task", help="remove a member task sidecar and branch before a retry"
|
|
@@ -4949,12 +5868,31 @@ def parser() -> argparse.ArgumentParser:
|
|
|
4949
5868
|
help="discard a rejected attempt whose Log the coordinator task holds")
|
|
4950
5869
|
retire_member_parser.add_argument("--task-file")
|
|
4951
5870
|
|
|
5871
|
+
runtime_hotfix_parser = subparsers.add_parser(
|
|
5872
|
+
"runtime-hotfix",
|
|
5873
|
+
help="authorize a runtime hotfix for one active task and re-verify it (#480)",
|
|
5874
|
+
)
|
|
5875
|
+
runtime_hotfix_parser.add_argument("--repo", type=Path, required=True)
|
|
5876
|
+
runtime_hotfix_parser.add_argument("--task-id", required=True)
|
|
5877
|
+
runtime_hotfix_parser.add_argument(
|
|
5878
|
+
"--runtime", required=True,
|
|
5879
|
+
help="64-hex runtime digest printed by runtime_store.py publish, validated before recording",
|
|
5880
|
+
)
|
|
5881
|
+
runtime_hotfix_parser.add_argument(
|
|
5882
|
+
"--authorized-by", required=True,
|
|
5883
|
+
help="the authorizing ruling, recorded verbatim in the task Log receipt",
|
|
5884
|
+
)
|
|
4952
5885
|
deactivate_task_parser = subparsers.add_parser(
|
|
4953
|
-
"deactivate-task",
|
|
5886
|
+
"deactivate-task",
|
|
5887
|
+
help="revoke one helper-owned task dispatch (parallel tasks only: a serial "
|
|
5888
|
+
"task has no task branch and no authorization to deactivate)",
|
|
4954
5889
|
)
|
|
4955
5890
|
deactivate_task_parser.add_argument("--repo", type=Path, required=True)
|
|
4956
5891
|
deactivate_task_parser.add_argument("--task-id", required=True)
|
|
4957
|
-
deactivate_task_parser.add_argument(
|
|
5892
|
+
deactivate_task_parser.add_argument(
|
|
5893
|
+
"--task-branch", required=True,
|
|
5894
|
+
help="the parallel isolate's branch gsd-path-task/<id>",
|
|
5895
|
+
)
|
|
4958
5896
|
|
|
4959
5897
|
isolate_verify_parser = subparsers.add_parser(
|
|
4960
5898
|
"isolate-verify", help="create a named verify sidecar"
|
|
@@ -5031,8 +5969,18 @@ def parser() -> argparse.ArgumentParser:
|
|
|
5031
5969
|
|
|
5032
5970
|
|
|
5033
5971
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
5034
|
-
|
|
5972
|
+
argument_parser = parser()
|
|
5973
|
+
arguments = argument_parser.parse_args(argv)
|
|
5035
5974
|
try:
|
|
5975
|
+
if arguments.command == "activate-member-task" and (
|
|
5976
|
+
(arguments.reauthorize and (arguments.agent or arguments.task_file or arguments.base))
|
|
5977
|
+
or (not arguments.reauthorize
|
|
5978
|
+
and not (arguments.agent and arguments.task_file and arguments.base))
|
|
5979
|
+
):
|
|
5980
|
+
argument_parser.error(
|
|
5981
|
+
"activate-member-task requires --agent, --task-file, and --base, "
|
|
5982
|
+
"or --reauthorize alone (which uses the recorded activation)"
|
|
5983
|
+
)
|
|
5036
5984
|
if arguments.command == "isolate-task":
|
|
5037
5985
|
result = isolate_task(
|
|
5038
5986
|
arguments.repo,
|
|
@@ -5052,14 +6000,24 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
5052
6000
|
elif arguments.command == "isolate-member-task":
|
|
5053
6001
|
result = isolate_member_task(arguments.repo, arguments.member, arguments.task_id)
|
|
5054
6002
|
elif arguments.command == "activate-member-task":
|
|
5055
|
-
|
|
5056
|
-
|
|
6003
|
+
if arguments.reauthorize:
|
|
6004
|
+
result = reauthorize_member_task(arguments.repo, arguments.member, arguments.task_id)
|
|
6005
|
+
else:
|
|
6006
|
+
result = activate_member_task(arguments.repo, arguments.member, arguments.task_id,
|
|
6007
|
+
arguments.agent, arguments.task_file, arguments.base)
|
|
5057
6008
|
elif arguments.command == "retire-member-task":
|
|
5058
6009
|
copy_rejected = retire_member_task(arguments.repo, arguments.member, arguments.task_id,
|
|
5059
6010
|
arguments.force, arguments.task_file)
|
|
5060
6011
|
result = {"member": arguments.member, "retired": True, "task_id": arguments.task_id}
|
|
5061
6012
|
if copy_rejected:
|
|
5062
6013
|
result["copy_rejected"] = copy_rejected
|
|
6014
|
+
elif arguments.command == "runtime-hotfix":
|
|
6015
|
+
result = runtime_hotfix(
|
|
6016
|
+
arguments.repo,
|
|
6017
|
+
arguments.task_id,
|
|
6018
|
+
arguments.runtime,
|
|
6019
|
+
arguments.authorized_by,
|
|
6020
|
+
)
|
|
5063
6021
|
elif arguments.command == "deactivate-task":
|
|
5064
6022
|
result = deactivate_task(
|
|
5065
6023
|
arguments.repo,
|