@seanyao/roll 2026.529.4 → 2026.529.5

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v2026.529.5
4
+
5
+ ### Fixed
6
+
7
+ - loop dashboard 恢复显示当天跑完的 cycle(读取端对齐项目本地事件)`[loop]`
8
+ - 非 Claude agent 执行阶段恢复存活心跳(修复心跳子进程 fork 早于 source)`[loop]`
9
+
3
10
  ## v2026.529.4
4
11
 
5
12
  ### Fixed
package/bin/roll CHANGED
@@ -4,7 +4,7 @@ set -euo pipefail
4
4
  # Roll — AI Agent Convention Manager
5
5
  # Single source of truth for how all AI coding agents behave.
6
6
 
7
- VERSION="2026.529.4"
7
+ VERSION="2026.529.5"
8
8
  ROLL_HOME="${ROLL_HOME:-${HOME}/.roll}"
9
9
  ROLL_CONFIG="${ROLL_HOME}/config.yaml"
10
10
  ROLL_GLOBAL="${ROLL_HOME}/conventions/global"
@@ -6079,8 +6079,12 @@ _heartbeat_writer() {
6079
6079
  sleep 60
6080
6080
  done
6081
6081
  }
6082
- _heartbeat_writer &
6083
- _HEARTBEAT_PID=\$!
6082
+ # FIX-138: _heartbeat_writer is started AFTER sourcing bin/roll below, NOT here.
6083
+ # Backgrounding a function forks a subshell snapshot of the current shell;
6084
+ # _loop_event (defined in bin/roll, sourced ~240 lines later) is undefined in
6085
+ # that snapshot, so every phase_tick call silently failed (2>/dev/null || true)
6086
+ # -> zero heartbeat across the whole cycle. The publish_wait_merge ticks seen
6087
+ # were its own 30s poll loop, not this writer. Starting post-source fixes it.
6084
6088
  # FIX-057: cycle hard timeout — 45 minute SLA per loop cycle. If a cycle runs
6085
6089
  # longer, kill claude / loop-fmt.py / all backgrounded children, mark the
6086
6090
  # in-progress backlog item Blocked (caller decides), and exit cleanly so the
@@ -6310,9 +6314,8 @@ _runs_append "failed" 0 "[]" "\$_phases_t" 2>/dev/null || true
6310
6314
  sed -E 's/\x1b\[[0-9;]*[A-Za-z]//g; s/\r$//' "\$ROLL_CYCLE_LOG_RAW" \
6311
6315
  > "\${_log_dir}/\${CYCLE_ID}.log" 2>/dev/null || true
6312
6316
  rm -f "\$ROLL_CYCLE_LOG_RAW"
6313
- # Rotate: keep newest ROLL_CYCLE_LOG_KEEP (default 50) .log files
6314
- _keep="\${ROLL_CYCLE_LOG_KEEP:-50}"
6315
- ( cd "\$_log_dir" && ls -t *.log 2>/dev/null | tail -n +\$((_keep + 1)) | xargs -r rm -f ) 2>/dev/null || true
6317
+ # FIX-139: keep ALL per-cycle logs no rotation cap. Each log is ~2KB,
6318
+ # full-year retention is a few MB. Owner: "不做 50 的限制,所有的都要留下来".
6316
6319
  fi
6317
6320
  rm -f "\$INNER_LOCK" "\$HEARTBEAT_FILE" "\$HEARTBEAT_PHASE_FILE"
6318
6321
  exit "\$_rc"
@@ -6326,6 +6329,11 @@ trap '_inner_cleanup' EXIT
6326
6329
  source "${roll_bin}"
6327
6330
  set +e
6328
6331
 
6332
+ # FIX-138: start heartbeat now that _loop_event is defined (see note at its
6333
+ # definition above). Forking earlier loses _loop_event in the subshell snapshot.
6334
+ _heartbeat_writer &
6335
+ _HEARTBEAT_PID=\$!
6336
+
6329
6337
  # FIX-052: bin/roll initializes loop state paths from cwd at source time, but
6330
6338
  # the inner script may be launched from anywhere. Override to this project's
6331
6339
  # slug (baked at template generation) so helpers like _worktree_alert write
@@ -6620,6 +6628,11 @@ if [ "\$_USE_WORKTREE" = "1" ]; then
6620
6628
  _phase_begin publish_wait_merge
6621
6629
  if ! ( cd "\$WT" && _loop_wait_pr_merge "\$BRANCH" ); then
6622
6630
  _worktree_alert "cycle \${CYCLE_ID}: FIX-047: PR not merged within timeout — code may not be in main (BRANCH=\${BRANCH})"
6631
+ # FIX-140: PR not merged => commits are NOT on main => story is NOT
6632
+ # done. Revert the routed story ✅ Done -> 📋 Todo so backlog reflects
6633
+ # reality instead of a false Done. (FIX-141's open-PR gate keeps the
6634
+ # next cycle from opening a duplicate PR while this one is still open.)
6635
+ [ -n "\${ROLL_LOOP_ROUTED_STORY:-}" ] && _loop_mark_todo "\$ROLL_LOOP_ROUTED_STORY" 2>/dev/null || true
6623
6636
  _phase_end publish_wait_merge fail
6624
6637
  else
6625
6638
  _phase_end publish_wait_merge ok
@@ -6769,7 +6782,11 @@ fi
6769
6782
  LOCK="\$(dirname "\$0")/.LOCK-\$(basename "\$0" .sh | sed 's/^run-//')"
6770
6783
  SESSION="roll-loop-\$(basename "\$0" .sh | sed 's/^run-//')"
6771
6784
  INNER_SCRIPT="${inner_path}"
6785
+ # FIX-139: machine/ops log path is supplied by the caller (now project-local
6786
+ # .roll/loop/cron.log — see the _write_loop_runner_script call site). Ensure its
6787
+ # directory exists before the early LOCK-skip echoes append to it.
6772
6788
  LOG="${log_path}"
6789
+ mkdir -p "\$(dirname "\$LOG")" 2>/dev/null || true
6773
6790
  if [ -f "\$LOCK" ]; then
6774
6791
  prev_pid=\$(head -1 "\$LOCK" 2>/dev/null || echo "")
6775
6792
  if [ -n "\$prev_pid" ] && kill -0 "\$prev_pid" 2>/dev/null; then
@@ -6809,7 +6826,10 @@ if command -v tmux >/dev/null 2>&1; then
6809
6826
  CYCLE_LOG_RAW="${project_path}/.roll/cycle-logs/.pipe-\$\$.raw"
6810
6827
  export ROLL_CYCLE_LOG_RAW="\$CYCLE_LOG_RAW"
6811
6828
  tmux new-session -d -s "\$SESSION" -x 200 -y 50 "bash \"\$INNER_SCRIPT\""
6812
- tmux pipe-pane -t "\$SESSION" "tee -a \"\$LOG\" >> \"\$ROLL_CYCLE_LOG_RAW\""
6829
+ # FIX-139: pane output goes ONLY to the per-cycle raw (-> <CYCLE_ID>.log),
6830
+ # no cumulative tee into LOG — per-cycle logs are the single source, all kept
6831
+ # (no cap). Machine/ops events still append to LOG directly.
6832
+ tmux pipe-pane -t "\$SESSION" "cat >> \"\$ROLL_CYCLE_LOG_RAW\""
6813
6833
  # Auto-attach popup: when not muted, spawn a Terminal.app window attached
6814
6834
  # to the tmux session so the user can watch the loop work in real time.
6815
6835
  # FIX-054: terminal selection removed — fixed to macOS Terminal.app for
@@ -6829,8 +6849,10 @@ if command -v tmux >/dev/null 2>&1; then
6829
6849
  # cron-<slug>.log file still has the full transcript as a fallback.
6830
6850
  # FIX-131: after tmux session ends, open the cron log with less so the
6831
6851
  # user can scroll through the full cycle output instead of seeing nothing.
6832
- printf '#!/bin/bash\\ntmux attach -t %s 2>/dev/null\\nLOGFILE=~/.shared/roll/loop/cron-%s.log\\necho\\nif [ -f "\$LOGFILE" ]; then\\n echo "================================================================"\\n echo " Cycle ended — showing log (arrows to scroll, q to close)"\\n echo "================================================================"\\n less -R +G "\$LOGFILE"\\nelse\\n echo "================================================================"\\n echo " Cycle ended. Log not found: \$LOGFILE"\\n echo " press enter to close."\\n echo "================================================================"\\n read _\\nfi\\n' \\
6833
- "\$SESSION" "${slug}" > "\$_attach_cmd" 2>/dev/null || true
6852
+ # FIX-139: after the session ends, show ONLY this cycle's per-cycle log
6853
+ # (newest in .roll/cycle-logs/), not the global cumulative transcript.
6854
+ printf '#!/bin/bash\\ntmux attach -t %s 2>/dev/null\\nLOGFILE=\$(ls -t "%s"/.roll/cycle-logs/*.log 2>/dev/null | head -1)\\necho\\nif [ -n "\$LOGFILE" ] && [ -f "\$LOGFILE" ]; then\\n echo "================================================================"\\n echo " Cycle ended — showing this cycle log (arrows to scroll, q to close)"\\n echo "================================================================"\\n less -R +G "\$LOGFILE"\\nelse\\n echo "================================================================"\\n echo " Cycle ended. No per-cycle log yet."\\n echo " press enter to close."\\n echo "================================================================"\\n read _\\nfi\\n' \\
6855
+ "\$SESSION" "${project_path}" > "\$_attach_cmd" 2>/dev/null || true
6834
6856
  chmod +x "\$_attach_cmd" 2>/dev/null || true
6835
6857
  open -g -a Terminal "\$_attach_cmd" >/dev/null 2>&1 || true
6836
6858
  fi
@@ -6972,7 +6994,11 @@ _install_launchd_plists() {
6972
6994
  local cmd; cmd=$(_agent_skill_cmd "${sd}/${skill}/SKILL.md" 2>/dev/null || echo "roll loop now")
6973
6995
 
6974
6996
  if [[ "$svc" == "loop" ]]; then
6975
- _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log" "$active_start" "$active_end" "${sd}/${skill}/SKILL.md"
6997
+ # FIX-139: loop machine/ops log is project-local (.roll/loop/cron.log),
6998
+ # not the global cron-<slug>.log. Reader (FIX-137) reads it project-local;
6999
+ # per-cycle transcripts live in .roll/cycle-logs/ (single source, no dup).
7000
+ local loop_log="${project_path}/.roll/loop/cron.log"
7001
+ _write_loop_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$loop_log" "$active_start" "$active_end" "${sd}/${skill}/SKILL.md"
6976
7002
  else
6977
7003
  _write_runner_script "$runner" "$project_path" "cd \"${project_path}\" && ${cmd}" "$log"
6978
7004
  fi
@@ -9650,6 +9676,15 @@ _loop_pick_next_story() {
9650
9676
  local backlog="${1:-.roll/backlog.md}"
9651
9677
  [ -f "$backlog" ] || return 1
9652
9678
 
9679
+ # FIX-141: fetch open PR titles ONCE so we can skip any story that already
9680
+ # has an open PR (it's in review / awaiting merge — re-picking it produces a
9681
+ # duplicate PR, as happened with FIX-137 #257 -> #258). gh runs in the cycle
9682
+ # worktree (has the remote); empty/unavailable gh => no skipping (safe).
9683
+ local _open_pr_titles=""
9684
+ if command -v gh >/dev/null 2>&1; then
9685
+ _open_pr_titles=$(gh pr list --state open --json title --jq '.[].title' 2>/dev/null || echo "")
9686
+ fi
9687
+
9653
9688
  # Two passes over the file, once per type prefix, return first hit.
9654
9689
  local prefix
9655
9690
  for prefix in FIX US REFACTOR; do
@@ -9670,6 +9705,11 @@ _loop_pick_next_story() {
9670
9705
  if ! _loop_check_depends_on "$id" "$backlog" >/dev/null 2>&1; then
9671
9706
  continue
9672
9707
  fi
9708
+ # Gate 3 (FIX-141): skip if an open PR already references this story id
9709
+ # (in its title) — avoids duplicate PRs for a story already in review.
9710
+ if [ -n "$_open_pr_titles" ] && printf '%s\n' "$_open_pr_titles" | grep -qE "${id}([^0-9A-Za-z]|$)"; then
9711
+ continue
9712
+ fi
9673
9713
  printf '%s\n' "$id"
9674
9714
  return 0
9675
9715
  done < "$backlog"
@@ -121,16 +121,101 @@ def _git_remote_url(repo_path: str) -> Optional[str]:
121
121
  def shared_root() -> Path:
122
122
  return Path(os.environ.get("ROLL_SHARED_ROOT") or os.path.expanduser("~/.shared/roll"))
123
123
 
124
+ # ════════════════════════════════════════════════════════════════════════════
125
+ # Project path resolution — mirrors bin/roll's _loop_resolve_project_path
126
+ # ════════════════════════════════════════════════════════════════════════════
127
+ def _resolve_project_path(slug: str) -> Optional[Path]:
128
+ """Mirror bin/roll's _loop_resolve_project_path: resolve a project slug
129
+ back to the absolute project root directory.
130
+
131
+ Priority chain (same as bash):
132
+ 1. ROLL_MAIN_PROJECT env var (set by cycle runner)
133
+ 2. macOS launchd plist WorkingDirectory for com.roll.loop.<slug>
134
+ 3. crontab entry referencing run-<slug>.sh
135
+ 4. inner runner script at ~/.shared/roll/loop/run-<slug>-inner.sh
136
+ """
137
+ # 1. Env var
138
+ env_proj = os.environ.get("ROLL_MAIN_PROJECT", "").strip()
139
+ if env_proj and Path(env_proj).is_dir():
140
+ return Path(env_proj)
141
+
142
+ # 2. macOS launchd plist
143
+ if sys.platform == "darwin":
144
+ plist = Path.home() / "Library" / "LaunchAgents" / f"com.roll.loop.{slug}.plist"
145
+ if plist.exists():
146
+ try:
147
+ text = plist.read_text(errors="ignore")
148
+ m = re.search(
149
+ r"<key>WorkingDirectory</key>\s*<string>([^<]+)</string>",
150
+ text
151
+ )
152
+ if m:
153
+ proj = Path(m.group(1))
154
+ if proj.is_dir():
155
+ return proj
156
+ except Exception:
157
+ pass
158
+
159
+ # 3. crontab
160
+ try:
161
+ cron_out = subprocess.check_output(
162
+ ["crontab", "-l"], stderr=subprocess.DEVNULL, text=True
163
+ )
164
+ for line in cron_out.splitlines():
165
+ if f"run-{slug}.sh" in line:
166
+ # Match: cd "<path>"
167
+ m = re.search(r'cd\s+"([^"]+)"', line)
168
+ if m:
169
+ proj = Path(m.group(1))
170
+ if proj.is_dir():
171
+ return proj
172
+ except Exception:
173
+ pass
174
+
175
+ # 4. Inner runner script (grep for ROLL_MAIN_PROJECT=)
176
+ inner_script = shared_root() / "loop" / f"run-{slug}-inner.sh"
177
+ if inner_script.exists():
178
+ try:
179
+ text = inner_script.read_text(errors="ignore")
180
+ m = re.search(r'export ROLL_MAIN_PROJECT="([^"]+)"', text)
181
+ if m:
182
+ proj = Path(m.group(1))
183
+ if proj.is_dir():
184
+ return proj
185
+ except Exception:
186
+ pass
187
+
188
+ return None
189
+
190
+
191
+ def _loop_runtime_dir_py(slug: str) -> Optional[Path]:
192
+ """Mirror bin/roll's _loop_runtime_dir: return <project>/.roll/loop."""
193
+ proj = _resolve_project_path(slug)
194
+ if proj is None:
195
+ return None
196
+ return proj / ".roll" / "loop"
197
+
124
198
  # ════════════════════════════════════════════════════════════════════════════
125
199
  # Loaders
126
200
  # ════════════════════════════════════════════════════════════════════════════
127
201
  def load_events(slug: str, days: int) -> List[Dict[str, Any]]:
128
- # US-LOOP-023: read the head NDJSON plus its rotated siblings .1..4.
129
- # bin/roll rotates events-<slug>.ndjson at 10MB keeping 4 archives; without
130
- # this loop the dashboard silently dropped any cycle whose events landed in
131
- # a rotated file (the "永久留存" promise of US-LOOP-004 only held on disk).
132
- head = shared_root() / "loop" / f"events-{slug}.ndjson"
133
- candidates = [head] + [head.with_suffix(f".ndjson.{i}") for i in range(1, 5)]
202
+ # FIX-137: read from project-local .roll/loop/events.ndjson first
203
+ # (mirrors _loop_event writer), fall back to shared events-<slug>.ndjson
204
+ # for historical data that hasn't been migrated yet.
205
+ candidates: List[Path] = []
206
+
207
+ # Primary: project-local (same path as _loop_event writer, US-LOOP-020)
208
+ rt_dir = _loop_runtime_dir_py(slug)
209
+ if rt_dir is not None:
210
+ head = rt_dir / "events.ndjson"
211
+ candidates.append(head)
212
+ candidates.extend(head.with_suffix(f".ndjson.{i}") for i in range(1, 5))
213
+
214
+ # Fallback: shared (old data pre-US-LOOP-020, migration not yet done)
215
+ shared_head = shared_root() / "loop" / f"events-{slug}.ndjson"
216
+ candidates.append(shared_head)
217
+ candidates.extend(shared_head.with_suffix(f".ndjson.{i}") for i in range(1, 5))
218
+
134
219
  existing = [p for p in candidates if p.exists()]
135
220
  if not existing:
136
221
  return []
@@ -171,8 +256,16 @@ _CRON_PAT = re.compile(
171
256
  )
172
257
 
173
258
  def load_cron_log(slug: str) -> List[Dict[str, Any]]:
174
- """Return ordered list of cron entries with local HH:MM:SS + extracted fields."""
175
- path = shared_root() / "loop" / f"cron-{slug}.log"
259
+ """Return ordered list of cron entries with local HH:MM:SS + extracted fields.
260
+
261
+ FIX-137: checks project-local .roll/loop/cron.log first, falls back to
262
+ shared cron-<slug>.log."""
263
+ # Primary: project-local
264
+ rt_dir = _loop_runtime_dir_py(slug)
265
+ path = (rt_dir / "cron.log") if rt_dir is not None else None
266
+ if path is None or not path.exists():
267
+ # Fallback: shared
268
+ path = shared_root() / "loop" / f"cron-{slug}.log"
176
269
  if not path.exists():
177
270
  return []
178
271
  out: List[Dict[str, Any]] = []
@@ -193,8 +286,16 @@ def load_cron_log(slug: str) -> List[Dict[str, Any]]:
193
286
  return out
194
287
 
195
288
  def load_state(slug: str) -> Dict[str, str]:
196
- """Tiny YAML reader — only the flat keys bin/roll writes."""
197
- path = shared_root() / "loop" / f"state-{slug}.yaml"
289
+ """Tiny YAML reader — only the flat keys bin/roll writes.
290
+
291
+ FIX-137: checks project-local .roll/loop/state.yaml first, falls back to
292
+ shared state-<slug>.yaml."""
293
+ # Primary: project-local
294
+ rt_dir = _loop_runtime_dir_py(slug)
295
+ path = (rt_dir / "state.yaml") if rt_dir is not None else None
296
+ if path is None or not path.exists():
297
+ # Fallback: shared
298
+ path = shared_root() / "loop" / f"state-{slug}.yaml"
198
299
  if not path.exists():
199
300
  return {}
200
301
  out: Dict[str, str] = {}
@@ -564,10 +665,18 @@ def repair_orphan_cycles_from_git(cycles: List[Dict[str, Any]], git_merges: Dict
564
665
 
565
666
  def load_runs(slug: str) -> Dict[str, Dict[str, Any]]:
566
667
  """Map run_id → run row for the current project (filters out other slugs
567
- sharing ~/.shared/roll/loop/runs.jsonl). Lenient slug matching salvages
568
- entries written under buggy slugs (FIX-053): the bare project basename
569
- (e.g. 'Roll') or worktree paths (e.g. '{slug}-cycle-XXX')."""
570
- path = shared_root() / "loop" / "runs.jsonl"
668
+ sharing runs.jsonl). Lenient slug matching salvages entries written under
669
+ buggy slugs (FIX-053): the bare project basename (e.g. 'Roll') or worktree
670
+ paths (e.g. '{slug}-cycle-XXX').
671
+
672
+ FIX-137: reads from project-local .roll/loop/runs.jsonl first, falls back
673
+ to shared runs.jsonl."""
674
+ # Primary: project-local
675
+ rt_dir = _loop_runtime_dir_py(slug)
676
+ path = (rt_dir / "runs.jsonl") if rt_dir is not None else None
677
+ if path is None or not path.exists():
678
+ # Fallback: shared (cross-project)
679
+ path = shared_root() / "loop" / "runs.jsonl"
571
680
  if not path.exists():
572
681
  return {}
573
682
  base = slug.split("-")[0] # 'Roll-a43d1b' → 'Roll'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seanyao/roll",
3
- "version": "2026.529.4",
3
+ "version": "2026.529.5",
4
4
  "description": "Roll — Roll out features with AI agents",
5
5
  "scripts": {
6
6
  "test": "bash tests/run.sh"
@@ -344,8 +344,12 @@ After each item completes:
344
344
 
345
345
  **Path B — heal exhausted (≥`ROLL_LOOP_HEAL_MAX`, default 2) or disabled (`ROLL_LOOP_NO_HEAL=1`) (exit 1):**
346
346
 
347
- 1. Keep story as ✅ Done commits are already on main; CI red is a follow-up
348
- problem, not a story failure.
347
+ 1. Do NOT force ✅ Done here. CI red means the PR will not merge, and
348
+ FIX-140's merge gate (in `bin/roll`, after `publish_wait_merge`) is the
349
+ single authority on final status: if the PR actually merges, the story
350
+ stays ✅ Done; if it does not merge, the gate reverts it ✅ Done → 📋 Todo
351
+ so BACKLOG never shows a false Done for code that isn't on main. (FIX-141
352
+ then stops the next cycle re-opening a duplicate PR while this one is open.)
349
353
  2. Write ALERT to `~/.shared/roll/loop/ALERT-<slug>.md` with:
350
354
  - story ID, time, commit SHA
351
355
  - heal attempts made (read `heal_count:` from `state-<slug>.yaml`)