Skip to content

Commit 53ff1d7

Browse files
vishnujayvelclaude
authored andcommitted
fix: scope OpenSpec sentinel per-change to prevent stale task queue across runs
The .openspec-populated sentinel was a boolean marker (touch file) that only tracked whether OpenSpec tasks had been loaded, not which change they came from. This caused three bugs: 1. Switching between OpenSpec changes reused stale tasks from the previous change 2. Running without --openspec still served leftover OpenSpec tasks to agents 3. Task ID collisions (openspec-N.M format) prevented new tasks from loading when old IDs matched via deduplication Fix: - Sentinel now stores the full change path; populate_openspec_queue() compares it on read and purges stale tasks when the change switches - CLI clears sentinel before adapter runs (--openspec provided) and removes all OpenSpec artifacts when --openspec is absent - Task IDs now include the change name (openspec-{name}-N.M) to prevent cross-change collisions Closes #150 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 0894c39 commit 53ff1d7

5 files changed

Lines changed: 77 additions & 17 deletions

File tree

autonomy/loki

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,9 @@ cmd_start() {
902902
# Export for run.sh to access
903903
export OPENSPEC_CHANGE_PATH="$openspec_change_path"
904904

905+
# Clear stale OpenSpec sentinel (will be re-created by run.sh for current change)
906+
rm -f "$LOKI_DIR/queue/.openspec-populated"
907+
905908
# Ensure .loki directory exists for adapter output
906909
mkdir -p "$LOKI_DIR"
907910

@@ -934,6 +937,33 @@ cmd_start() {
934937
fi
935938
fi
936939

940+
# When --openspec is not provided, clean up leftover OpenSpec state
941+
if [[ -z "$openspec_change_path" ]]; then
942+
rm -f "$LOKI_DIR/queue/.openspec-populated"
943+
rm -f "$LOKI_DIR/openspec-tasks.json"
944+
rm -f "$LOKI_DIR/openspec-prd-normalized.md"
945+
rm -rf "$LOKI_DIR/openspec/"
946+
# Also purge any stale OpenSpec tasks from pending queue
947+
# NOTE: Similar purge logic exists in autonomy/run.sh (change-switch cleanup)
948+
if [[ -f "$LOKI_DIR/queue/pending.json" ]]; then
949+
LOKI_QUEUE_DIR="$LOKI_DIR" python3 -c "
950+
import json, os
951+
try:
952+
queue_dir = os.environ['LOKI_QUEUE_DIR']
953+
pending_path = os.path.join(queue_dir, 'queue', 'pending.json')
954+
with open(pending_path) as f:
955+
tasks = json.load(f)
956+
filtered = [t for t in tasks if not (isinstance(t, dict) and t.get('source') == 'openspec')]
957+
if len(filtered) < len(tasks):
958+
with open(pending_path, 'w') as f:
959+
json.dump(filtered, f, indent=2)
960+
print(f'Cleaned {len(tasks) - len(filtered)} stale OpenSpec tasks from queue')
961+
except Exception as e:
962+
print(f'Warning: OpenSpec queue cleanup failed: {e}', file=__import__('sys').stderr)
963+
" 2>/dev/null
964+
fi
965+
fi
966+
937967
# MiroFish market validation (optional, non-blocking)
938968
if [[ -z "$mirofish_url" ]] && [[ -n "${LOKI_MIROFISH_URL:-}" ]] && [[ "$mirofish_disabled" != "true" ]]; then
939969
mirofish_url="$LOKI_MIROFISH_URL"

autonomy/openspec-adapter.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -345,9 +345,13 @@ def _parse_scenario(name: str, body: str) -> Dict[str, Any]:
345345

346346
# -- Tasks Parsing ------------------------------------------------------------
347347

348-
def parse_tasks(tasks_path: Path) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
348+
def parse_tasks(tasks_path: Path, change_name: str = "") -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
349349
"""Parse tasks.md into structured task list and source map.
350350
351+
Args:
352+
tasks_path: Path to tasks.md
353+
change_name: Name of the OpenSpec change (used to scope task IDs)
354+
351355
Returns:
352356
(tasks_list, source_map)
353357
tasks_list: list of task objects
@@ -373,7 +377,7 @@ def parse_tasks(tasks_path: Path) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[
373377
checked = task_match.group(1).lower() == "x"
374378
task_id_num = task_match.group(2)
375379
description = task_match.group(3).strip()
376-
task_id = f"openspec-{task_id_num}"
380+
task_id = f"openspec-{change_name}-{task_id_num}" if change_name else f"openspec-{task_id_num}"
377381

378382
task = {
379383
"id": task_id,
@@ -696,7 +700,7 @@ def run(
696700
source_map: Dict[str, Dict[str, Any]] = {}
697701
tasks_path = change_dir / "tasks.md"
698702
if tasks_path.exists():
699-
tasks_list, source_map = parse_tasks(tasks_path)
703+
tasks_list, source_map = parse_tasks(tasks_path, change_name=change_name)
700704

701705
# -- Parse design.md (optional) --
702706
design_data: Optional[Dict[str, str]] = None

autonomy/run.sh

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8740,10 +8740,33 @@ populate_openspec_queue() {
87408740
return 0
87418741
fi
87428742

8743-
# Skip if already populated (marker file)
8743+
# Skip if already populated for the SAME change (sentinel stores change path)
87448744
if [[ -f ".loki/queue/.openspec-populated" ]]; then
8745-
log_info "OpenSpec queue already populated, skipping"
8746-
return 0
8745+
local stored_change
8746+
stored_change="$(cat ".loki/queue/.openspec-populated" 2>/dev/null)"
8747+
if [[ "$stored_change" == "$OPENSPEC_CHANGE_PATH" ]]; then
8748+
log_info "OpenSpec queue already populated for this change, skipping"
8749+
return 0
8750+
else
8751+
log_info "OpenSpec change switched (was: $stored_change, now: $OPENSPEC_CHANGE_PATH) -- purging stale tasks"
8752+
# Purge stale OpenSpec tasks from pending queue before re-populating
8753+
# NOTE: Similar purge logic exists in autonomy/loki (no-openspec cleanup)
8754+
if [[ -f ".loki/queue/pending.json" ]]; then
8755+
python3 -c "
8756+
import json
8757+
try:
8758+
with open('.loki/queue/pending.json') as f:
8759+
tasks = json.load(f)
8760+
filtered = [t for t in tasks if not (isinstance(t, dict) and t.get('source') == 'openspec')]
8761+
if len(filtered) < len(tasks):
8762+
with open('.loki/queue/pending.json', 'w') as f:
8763+
json.dump(filtered, f, indent=2)
8764+
print(f'Purged {len(tasks) - len(filtered)} stale OpenSpec tasks from queue')
8765+
except Exception as e:
8766+
print(f'Warning: OpenSpec queue purge failed: {e}', file=__import__('sys').stderr)
8767+
" 2>/dev/null
8768+
fi
8769+
fi
87478770
fi
87488771

87498772
log_step "Populating task queue from OpenSpec tasks..."
@@ -8816,8 +8839,8 @@ OPENSPEC_QUEUE_EOF
88168839
return 0
88178840
fi
88188841

8819-
# Mark as populated so we don't re-add on restart
8820-
touch ".loki/queue/.openspec-populated"
8842+
# Mark as populated for this specific change so we don't re-add on restart
8843+
echo "$OPENSPEC_CHANGE_PATH" > ".loki/queue/.openspec-populated"
88218844
log_info "OpenSpec queue population complete"
88228845
}
88238846

skills/openspec-integration.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Tasks are generated from OpenSpec `tasks.md` and loaded into `.loki/queue/pendin
7676

7777
```json
7878
{
79-
"id": "openspec-1.3",
79+
"id": "openspec-session-hardening-1.3",
8080
"title": "Implement session timeout change",
8181
"description": "[OpenSpec] Authentication: Implement session timeout change",
8282
"priority": "medium",
@@ -143,9 +143,9 @@ The verification map tracks each scenario with `"verified": false` initially. Af
143143

144144
```json
145145
{
146-
"openspec-1.1": { "file": "tasks.md", "line": 3, "group": "Authentication" },
147-
"openspec-1.2": { "file": "tasks.md", "line": 4, "group": "Authentication" },
148-
"openspec-2.1": { "file": "tasks.md", "line": 7, "group": "Dashboard" }
146+
"openspec-session-hardening-1.1": { "file": "tasks.md", "line": 3, "group": "Authentication" },
147+
"openspec-session-hardening-1.2": { "file": "tasks.md", "line": 4, "group": "Authentication" },
148+
"openspec-session-hardening-2.1": { "file": "tasks.md", "line": 7, "group": "Dashboard" }
149149
}
150150
```
151151

tests/test_openspec_adapter.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,16 +163,19 @@ def test_task_groups_extracted(self, output_dir):
163163
assert len(groups) >= 3, f"Expected at least 3 groups, got {groups}"
164164

165165
def test_task_ids_hierarchical(self, output_dir):
166-
"""Task IDs should be in openspec-N.M format."""
166+
"""Task IDs should be in openspec-{change_name}-N.M format."""
167167
rc, stdout, _ = run_adapter(FIXTURES_DIR / "simple-feature", output_dir, "--json")
168168
assert rc == 0
169169
data = json.loads(stdout)
170170
for task in data["tasks"]:
171171
assert task["id"].startswith("openspec-"), f"ID {task['id']} should start with openspec-"
172-
# Should match openspec-N.M pattern
173-
suffix = task["id"].replace("openspec-", "")
174-
parts = suffix.split(".")
175-
assert len(parts) == 2, f"ID suffix {suffix} should be N.M"
172+
# Should match openspec-{change_name}-N.M pattern
173+
prefix = "openspec-simple-feature-"
174+
assert task["id"].startswith(prefix), \
175+
f"ID {task['id']} should start with {prefix}"
176+
num_part = task["id"][len(prefix):] # e.g., "1.1"
177+
parts = num_part.split(".")
178+
assert len(parts) == 2, f"Numeric suffix {num_part} should be N.M"
176179
assert parts[0].isdigit() and parts[1].isdigit()
177180

178181
def test_malformed_tasks_no_checkboxes(self, output_dir):

0 commit comments

Comments
 (0)