Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/sdlc-orchestrator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,13 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
ISSUE_LABEL: ${{ inputs.issue_label }}
GITHUB_RUN_ID: ${{ github.run_id }}
run: |
cargo run --quiet -- run sdlc-auto --strict

- name: Release claim on failure/cancel
if: ${{ always() && inputs.mode == 'auto' && (failure() || cancelled()) }}
env:
GH_TOKEN: ${{ github.token }}
run: |
scripts/automation/release_claim.sh .tutti/state/auto/selected_issue.json "workflow ${{ job.status }}"
Comment on lines +78 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Smoke dispatches now have no claim cleanup path.

Line 62 still runs sdlc-smoke, and that workflow selects an issue via select_issue.sh. Because this cleanup step is gated to inputs.mode == 'auto', smoke runs can apply automation-claimed without any matching release on success or failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/sdlc-orchestrator.yml around lines 78 - 83, The cleanup
step is currently gated by "inputs.mode == 'auto'" so smoke dispatches that run
sdlc-smoke and call select_issue.sh never get their claim released; remove the
inputs.mode == 'auto' check from the step's if expression (keep always() &&
(failure() || cancelled()) or change to always() to run on success too) so the
Release claim on failure/cancel step will run for smoke runs, and add a safety
check before invoking scripts/automation/release_claim.sh to only call it if the
claim file (.tutti/state/auto/selected_issue.json) exists to avoid spurious
errors; reference the Release claim on failure/cancel step,
scripts/automation/release_claim.sh, and select_issue.sh/sdlc-smoke to locate
where to change the condition and add the existence check.

16 changes: 15 additions & 1 deletion docs/examples/tutti-codex-sdlc.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,14 @@ id = "ensure_docs_release"
type = "ensure_running"
agent = "docs-release"

# 2) Intake and branch setup
# 2) Sweep stale claims before selecting a new issue
[[workflow.step]]
id = "sweep_stale_claims"
type = "command"
run = "scripts/automation/sweep_stale_claims.sh .tutti/state/claims"
fail_mode = "open"

# 3) Intake and branch setup
[[workflow.step]]
id = "select_issue"
type = "command"
Expand Down Expand Up @@ -222,3 +229,10 @@ inject_files = [".tutti/state/auto/selected_issue.json", ".tutti/state/auto/bran
wait_for_idle = true
wait_timeout_secs = 900
text = "Summarize final readiness, residual risks, and merge recommendation."

# 10) Release claim after successful completion
[[workflow.step]]
id = "release_claim"
type = "command"
run = "scripts/automation/release_claim.sh .tutti/state/auto/selected_issue.json 'workflow completed successfully'"
fail_mode = "open"
Comment on lines +233 to +238

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make successful-run claim release blocking.

This is the only success-path cleanup in sdlc-auto. With fail_mode = "open", a GitHub/auth error in release_claim.sh still leaves the workflow green and the issue claimed, and the job-level cleanup only runs on failure/cancel.

💡 Minimal config fix
 [[workflow.step]]
 id = "release_claim"
 type = "command"
 run = "scripts/automation/release_claim.sh .tutti/state/auto/selected_issue.json 'workflow completed successfully'"
-fail_mode = "open"
+fail_mode = "closed"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# 10) Release claim after successful completion
[[workflow.step]]
id = "release_claim"
type = "command"
run = "scripts/automation/release_claim.sh .tutti/state/auto/selected_issue.json 'workflow completed successfully'"
fail_mode = "open"
# 10) Release claim after successful completion
[[workflow.step]]
id = "release_claim"
type = "command"
run = "scripts/automation/release_claim.sh .tutti/state/auto/selected_issue.json 'workflow completed successfully'"
fail_mode = "closed"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/examples/tutti-codex-sdlc.toml` around lines 233 - 238, The
release_claim workflow step currently uses fail_mode = "open", which lets errors
in scripts/release_claim.sh leave the workflow green and the issue still
claimed; update the step with id "release_claim" to use fail_mode = "closed"
(make it blocking) so any GitHub/auth errors in
scripts/automation/release_claim.sh cause the workflow to fail and ensure the
claim release must succeed.

43 changes: 43 additions & 0 deletions scripts/automation/release_claim.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail

# Release the automation-claimed label from the selected issue.
# Usage: release_claim.sh [selected_issue_json] [reason]

ISSUE_FILE="${1:-.tutti/state/auto/selected_issue.json}"
REASON="${2:-workflow completed}"

if [ ! -f "$ISSUE_FILE" ]; then
echo "No selected issue file at $ISSUE_FILE — nothing to release." >&2
exit 0
fi

REPO="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"

ISSUE_NUM=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['issue_number'])" "$ISSUE_FILE")

if [ -z "$ISSUE_NUM" ]; then
echo "Could not read issue_number from $ISSUE_FILE" >&2
exit 1
fi

# Remove the automation-claimed label (tolerate it already being absent).
gh issue edit "$ISSUE_NUM" --repo "$REPO" --remove-label "automation-claimed" 2>/dev/null || true

# Extract run_id from claim metadata if available.
RUN_ID=$(python3 -c "
import json,sys
d = json.load(open(sys.argv[1]))
print(d.get('claim', {}).get('run_id', 'unknown'))
" "$ISSUE_FILE" 2>/dev/null || echo "unknown")

# Post audit comment.
gh issue comment "$ISSUE_NUM" --repo "$REPO" \
--body "🤖 **Claim released** — reason: ${REASON} (run \`${RUN_ID}\`)" \
>/dev/null 2>&1 || true

# Remove claim state file.
CLAIM_FILE=".tutti/state/claims/${ISSUE_NUM}.json"
rm -f "$CLAIM_FILE" 2>/dev/null || true
Comment on lines +24 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't drop the local lease unless the GitHub label was actually removed.

This swallows every gh issue edit failure, not just “label already absent”, and then deletes .tutti/state/claims/${ISSUE_NUM}.json anyway. A transient GH/auth error will leave automation-claimed on the issue with no local lease left for the sweeper to recover.

💡 Suggested hardening
-# Remove the automation-claimed label (tolerate it already being absent).
-gh issue edit "$ISSUE_NUM" --repo "$REPO" --remove-label "automation-claimed" 2>/dev/null || true
+# Remove the automation-claimed label, but only tolerate "already absent".
+REMOVE_ERR=""
+if ! REMOVE_ERR=$(gh issue edit "$ISSUE_NUM" --repo "$REPO" --remove-label "automation-claimed" 2>&1); then
+  case "$REMOVE_ERR" in
+    *"not found"*|*"does not have"*)
+      ;;
+    *)
+      echo "Failed to remove automation-claimed from issue #${ISSUE_NUM}: $REMOVE_ERR" >&2
+      exit 1
+      ;;
+  esac
+fi
@@
-# Remove claim state file.
-CLAIM_FILE=".tutti/state/claims/${ISSUE_NUM}.json"
-rm -f "$CLAIM_FILE" 2>/dev/null || true
+# Remove claim state file only after label removal succeeded.
+CLAIM_FILE=".tutti/state/claims/${ISSUE_NUM}.json"
+rm -f "$CLAIM_FILE"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/automation/release_claim.sh` around lines 24 - 41, The script
currently ignores all failures from the gh issue edit call and always removes
the local claim file; change it so the local lease (CLAIM_FILE) is deleted only
when the GitHub label removal actually succeeded or when the failure is the
specific “label already absent” condition. Concretely, run gh issue edit
"$ISSUE_NUM" --repo "$REPO" and capture its exit status/output instead of
swallowing errors, check for success (exit code 0) or a recognized “label not
found/absent” message, and only then proceed to remove CLAIM_FILE
(.tutti/state/claims/${ISSUE_NUM}.json) and post the comment using RUN_ID;
otherwise log/emit the error and leave the claim file in place so the sweeper
can recover.


echo "Released claim on issue #${ISSUE_NUM}: ${REASON}"
42 changes: 41 additions & 1 deletion scripts/automation/select_issue.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,58 @@ PY

gh issue edit "$ISSUE_NUM" --repo "$REPO" --add-label "automation-claimed" >/dev/null

# Generate a unique run ID for claim tracking.
RUN_ID="${GITHUB_RUN_ID:-local-$(date +%s)-$$}"
LEASE_TTL="${CLAIM_LEASE_TTL:-1800}"

ISSUE_DETAILS=$(gh issue view "$ISSUE_NUM" --repo "$REPO" --json body)
python3 - "$OUT_FILE" "$ISSUE_DETAILS" <<'PY'
python3 - "$OUT_FILE" "$ISSUE_DETAILS" "$RUN_ID" "$LEASE_TTL" "$REPO" <<'PY'
import json,sys
from datetime import datetime, timezone
out, details_raw = sys.argv[1], sys.argv[2]
run_id, lease_ttl, repo = sys.argv[3], int(sys.argv[4]), sys.argv[5]
with open(f"{out}.tmp", "r", encoding="utf-8") as f:
payload = json.load(f)
details = json.loads(details_raw or "{}")
payload["body"] = (details.get("body") or "").strip()
# Embed claim metadata into the selected issue state.
now = datetime.now(timezone.utc).isoformat()
payload["claim"] = {
"run_id": run_id,
"claimed_at": now,
"renewed_at": now,
"lease_ttl_secs": lease_ttl,
"repo": repo,
}
with open(f"{out}.tmp", "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
PY

mv "${OUT_FILE}.tmp" "$OUT_FILE"

# Persist claim lease for the Rust-side sweeper / auto-release.
CLAIMS_DIR="$(dirname "$OUT_FILE")/../../state/claims"
mkdir -p "$CLAIMS_DIR"
Comment on lines +73 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Write the lease to the canonical claims directory.

src/claim/mod.rs and scripts/automation/sweep_stale_claims.sh always read .tutti/state/claims from the project root. If callers override OUT_FILE, $(dirname "$OUT_FILE")/../../state/claims can resolve somewhere else, and the Rust auto-release / stale sweeper won't find the lease you just created.

💡 Minimal fix
-CLAIMS_DIR="$(dirname "$OUT_FILE")/../../state/claims"
+CLAIMS_DIR=".tutti/state/claims"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/automation/select_issue.sh` around lines 73 - 75, The script
currently writes leases to a claims dir derived from OUT_FILE which can be
overridden; change CLAIMS_DIR to the canonical project-root path used by Rust
and sweep scripts (the repository root + /.tutti/state/claims) instead of
"$(dirname "$OUT_FILE")/../../state/claims". Update the CLAIMS_DIR assignment in
select_issue.sh to compute the repo root (e.g., via git rev-parse
--show-toplevel or a reliable project-root heuristic) and then mkdir -p that
canonical "$REPO_ROOT/.tutti/state/claims" so src/claim/mod.rs and
scripts/automation/sweep_stale_claims.sh will always find the lease.

python3 - "$CLAIMS_DIR" "$ISSUE_NUM" "$RUN_ID" "$LEASE_TTL" "$REPO" <<'PY'
import json,sys,os
from datetime import datetime, timezone
claims_dir, issue_num = sys.argv[1], sys.argv[2]
run_id, lease_ttl, repo = sys.argv[3], int(sys.argv[4]), sys.argv[5]
now = datetime.now(timezone.utc).isoformat()
lease = {
"issue_number": int(issue_num),
"repo": repo,
"run_id": run_id,
"claimed_at": now,
"renewed_at": now,
"lease_ttl_secs": lease_ttl,
}
path = os.path.join(claims_dir, f"{issue_num}.json")
with open(path, "w", encoding="utf-8") as f:
json.dump(lease, f, indent=2)
PY

# Post audit comment on the issue.
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body "🤖 **Claim acquired** by run \`$RUN_ID\` — lease ${LEASE_TTL}s" >/dev/null 2>&1 || true

echo "$OUT_FILE"
67 changes: 67 additions & 0 deletions scripts/automation/sweep_stale_claims.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail

# Sweep stale claim leases whose TTL has expired.
# Usage: sweep_stale_claims.sh [claims_dir]

CLAIMS_DIR="${1:-.tutti/state/claims}"

if [ ! -d "$CLAIMS_DIR" ]; then
echo "No claims directory at $CLAIMS_DIR — nothing to sweep."
exit 0
fi

REPO="${GITHUB_REPOSITORY:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"

python3 - "$CLAIMS_DIR" "$REPO" <<'PY'
import json, sys, os, subprocess
from datetime import datetime, timezone

claims_dir, repo = sys.argv[1], sys.argv[2]
released = 0

for fname in os.listdir(claims_dir):
if not fname.endswith(".json"):
continue
path = os.path.join(claims_dir, fname)
try:
with open(path) as f:
lease = json.load(f)
except (json.JSONDecodeError, OSError):
continue

renewed_at = datetime.fromisoformat(lease["renewed_at"].replace("Z", "+00:00"))
ttl = lease.get("lease_ttl_secs", 1800)
now = datetime.now(timezone.utc)
elapsed = (now - renewed_at).total_seconds()

if elapsed <= ttl:
remaining = int(ttl - elapsed)
print(f" active: issue #{lease['issue_number']} (run={lease['run_id']}, {remaining}s remaining)")
continue

issue_num = lease["issue_number"]
run_id = lease.get("run_id", "unknown")
expired_ago = int(elapsed - ttl)
print(f" stale: issue #{issue_num} (run={run_id}, expired {expired_ago}s ago) — releasing")

# Remove label.
subprocess.run(
["gh", "issue", "edit", str(issue_num), "--repo", repo, "--remove-label", "automation-claimed"],
capture_output=True,
)
# Post audit comment.
subprocess.run(
["gh", "issue", "comment", str(issue_num), "--repo", repo,
"--body", f"🤖 **Claim released** — reason: lease expired (sweeper, run `{run_id}`)"],
capture_output=True,
)
# Remove claim file.
os.remove(path)
released += 1
Comment on lines +48 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only delete a stale lease after the GitHub release succeeds.

Both subprocess.run(...) calls ignore their return codes, but os.remove(path) and released += 1 still happen. A temporary GitHub failure will report the claim as released while leaving the issue labeled and deleting the only state the next sweep could use to repair it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/automation/sweep_stale_claims.sh` around lines 48 - 61, The two
subprocess.run(...) calls that call GitHub (the "gh issue edit" and "gh issue
comment") currently ignore failures yet the script always calls os.remove(path)
and increments released; change the logic in the sweep loop so you only remove
the claim file (os.remove(path)) and increment released when both subprocess
calls succeeded: either call subprocess.run with check=True (or inspect
.returncode == 0) for the "gh issue edit" and "gh issue comment" invocations and
skip deletion/increment on failure (log or capture the error instead); ensure
the change is applied where the current subprocess.run(...) calls and the
os.remove(path)/released += 1 statements appear so a temporary GitHub failure
does not delete the only recovery state.


if released == 0:
print("sweep: no stale claims found")
else:
print(f"sweep: released {released} stale claim(s)")
PY
15 changes: 15 additions & 0 deletions src/automation/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::claim;
use crate::config::{
HookConfig, HookEvent, HookWorkflowSource, PermissionsConfig, ResilienceConfig, TuttiConfig,
WorkflowCommandCwd, WorkflowConfig, WorkflowFailMode, WorkflowStepConfig,
Expand Down Expand Up @@ -3062,6 +3063,20 @@ pub fn execute_workflow_with_hooks(
let result = executor.execute(resolved, options, agent_scope, Some(&run_id), resume)?;
reclaim_non_persistent_sessions(config, project_root, &running_before)?;

// Auto-release claim on workflow failure so issues don't stay permanently blocked.
if !result.success
&& let Ok(Some(issue_num)) = claim::load_selected_issue_number(project_root)
&& let Ok(Some(_lease)) = claim::load_claim(project_root, issue_num)
{
let reason = format!(
"workflow `{}` failed (steps {:?})",
result.workflow_name, result.failed_steps
);
if let Err(e) = claim::release_claim(project_root, issue_num, &reason) {
eprintln!("claim: auto-release failed for issue #{}: {}", issue_num, e);
}
}
Comment on lines +3066 to +3078

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Scope claim auto-release to the top-level owner run.

execute_workflow_with_hooks() is also used for nested workflows and hooks (Line 1302, Line 2859, Line 2936). A nested workflow that returns success = false under an open parent fail mode (Line 1329) will hit this block and release the selected issue while the outer automation keeps running. The extra load_claim(...).is_some() guard also skips claim::release_claim()'s fallback when the issue file exists but the lease file was never written.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/automation/mod.rs` around lines 3066 - 3078, This auto-release runs for
nested workflows; restrict it to only the top-level owner run and let
release_claim handle missing lease files: inside execute_workflow_with_hooks,
replace the current multi-check guard (which uses
claim::load_selected_issue_number and claim::load_claim) with a single check
that this invocation is the top-level owner run (e.g., an existing is_top_level
/ owner_run_id equality or similar flag passed into execute_workflow_with_hooks)
and that claim::load_selected_issue_number(project_root) returns
Some(issue_num); then call claim::release_claim(project_root, issue_num,
&reason) directly (remove the extra load_claim(...).is_some() guard) so the
release_claim fallback logic still runs when lease file is absent; keep the same
error logging on Err.


// Recursion guard: don't emit workflow_complete from workflow_complete hooks.
if options.origin != ExecutionOrigin::HookWorkflowComplete {
let payload = WorkflowCompletePayload {
Expand Down
Loading
Loading