dsh-compound-engineering 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +160 -2
- package/README.zh.md +161 -0
- package/cordis.patch.yml +6 -0
- package/lib/frontmatter.js +167 -0
- package/lib/index.js +46 -0
- package/lib/provider.js +201 -0
- package/package.json +62 -4
- package/skills/CODING_STANDARDS.md +68 -0
- package/skills/ce-babysit-pr/SKILL.md +59 -0
- package/skills/ce-babysit-pr/references/branch-currency.md +22 -0
- package/skills/ce-babysit-pr/references/envelope.md +44 -0
- package/skills/ce-babysit-pr/references/pipeline.md +15 -0
- package/skills/ce-babysit-pr/references/report.md +20 -0
- package/skills/ce-babysit-pr/references/settle.md +37 -0
- package/skills/ce-babysit-pr/references/setup.md +33 -0
- package/skills/ce-babysit-pr/references/stack-commands.md +39 -0
- package/skills/ce-babysit-pr/references/stack.md +43 -0
- package/skills/ce-babysit-pr/references/tick.md +102 -0
- package/skills/ce-babysit-pr/references/watch-loop.md +222 -0
- package/skills/ce-babysit-pr/scripts/pr-snapshot +3656 -0
- package/skills/ce-bakeoff/SKILL.md +48 -0
- package/skills/ce-bakeoff/references/candidates.md +37 -0
- package/skills/ce-bakeoff/references/judging.md +19 -0
- package/skills/ce-bakeoff/references/output.md +11 -0
- package/skills/ce-bakeoff/references/verification.md +15 -0
- package/skills/ce-brainstorm/SKILL.md +69 -0
- package/skills/ce-brainstorm/references/agents/slack-researcher.md +127 -0
- package/skills/ce-brainstorm/references/approaches.md +57 -0
- package/skills/ce-brainstorm/references/bakeoff.md +9 -0
- package/skills/ce-brainstorm/references/blindspot-pass.md +70 -0
- package/skills/ce-brainstorm/references/brainstorm-sections.md +458 -0
- package/skills/ce-brainstorm/references/dialogue.md +89 -0
- package/skills/ce-brainstorm/references/handoff.md +187 -0
- package/skills/ce-brainstorm/references/html-rendering.md +638 -0
- package/skills/ce-brainstorm/references/interaction-rules.md +33 -0
- package/skills/ce-brainstorm/references/markdown-rendering.md +242 -0
- package/skills/ce-brainstorm/references/model-tiers.md +13 -0
- package/skills/ce-brainstorm/references/output-mode.md +26 -0
- package/skills/ce-brainstorm/references/phase-0.md +104 -0
- package/skills/ce-brainstorm/references/plan-write.md +32 -0
- package/skills/ce-brainstorm/references/product-pressure-test.md +42 -0
- package/skills/ce-brainstorm/references/reasoning-elevation.md +124 -0
- package/skills/ce-brainstorm/references/settled-decisions.md +45 -0
- package/skills/ce-brainstorm/references/synthesis-summary.md +149 -0
- package/skills/ce-brainstorm/references/universal-brainstorming.md +72 -0
- package/skills/ce-brainstorm/references/verdict-routing.md +26 -0
- package/skills/ce-brainstorm/references/visual-probes.md +164 -0
- package/skills/ce-brainstorm/scripts/elevation-dispatch.sh +288 -0
- package/skills/ce-brainstorm/scripts/light-webserver.js +1275 -0
- package/skills/ce-brainstorm/scripts/packs-resolve.py +737 -0
- package/skills/ce-brainstorm/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-code-review/SKILL.md +40 -0
- package/skills/ce-code-review/references/action-class-rubric.md +67 -0
- package/skills/ce-code-review/references/cross-model-recovery.md +38 -0
- package/skills/ce-code-review/references/cross-model-review.md +191 -0
- package/skills/ce-code-review/references/depth-paths.md +32 -0
- package/skills/ce-code-review/references/diff-scope.md +59 -0
- package/skills/ce-code-review/references/dispatch-reviewers.md +135 -0
- package/skills/ce-code-review/references/findings-schema.json +125 -0
- package/skills/ce-code-review/references/finish-input.md +118 -0
- package/skills/ce-code-review/references/finish-review.md +202 -0
- package/skills/ce-code-review/references/intent-and-plan.md +73 -0
- package/skills/ce-code-review/references/modes-and-output.md +156 -0
- package/skills/ce-code-review/references/persona-catalog.md +66 -0
- package/skills/ce-code-review/references/personas/adversarial-reviewer.md +111 -0
- package/skills/ce-code-review/references/personas/agent-native-reviewer.md +173 -0
- package/skills/ce-code-review/references/personas/api-contract-reviewer.md +44 -0
- package/skills/ce-code-review/references/personas/correctness-reviewer.md +48 -0
- package/skills/ce-code-review/references/personas/data-migration-reviewer.md +111 -0
- package/skills/ce-code-review/references/personas/deployment-verification-agent.md +157 -0
- package/skills/ce-code-review/references/personas/julik-frontend-races-reviewer.md +45 -0
- package/skills/ce-code-review/references/personas/learnings-researcher.md +260 -0
- package/skills/ce-code-review/references/personas/maintainability-reviewer.md +85 -0
- package/skills/ce-code-review/references/personas/performance-reviewer.md +45 -0
- package/skills/ce-code-review/references/personas/previous-comments-reviewer.md +59 -0
- package/skills/ce-code-review/references/personas/project-standards-reviewer.md +77 -0
- package/skills/ce-code-review/references/personas/reliability-reviewer.md +49 -0
- package/skills/ce-code-review/references/personas/security-reviewer.md +50 -0
- package/skills/ce-code-review/references/personas/swift-ios-reviewer.md +99 -0
- package/skills/ce-code-review/references/personas/testing-reviewer.md +51 -0
- package/skills/ce-code-review/references/review-output-template.md +172 -0
- package/skills/ce-code-review/references/scope.md +176 -0
- package/skills/ce-code-review/references/select-and-route.md +93 -0
- package/skills/ce-code-review/references/subagent-template.md +206 -0
- package/skills/ce-code-review/references/validator-batch-template.md +67 -0
- package/skills/ce-code-review/scripts/cross-model-adversarial-review.sh +1275 -0
- package/skills/ce-code-review/scripts/findings-mechanics.py +274 -0
- package/skills/ce-code-review/scripts/packs-resolve.py +737 -0
- package/skills/ce-code-review/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-code-review/scripts/review-scope.py +419 -0
- package/skills/ce-code-review/scripts/run-log.py +280 -0
- package/skills/ce-commit/SKILL.md +60 -0
- package/skills/ce-commit-push-pr/SKILL.md +69 -0
- package/skills/ce-commit-push-pr/references/apply-and-handoff.md +79 -0
- package/skills/ce-commit-push-pr/references/branch-creation.md +47 -0
- package/skills/ce-commit-push-pr/references/commit-and-push.md +26 -0
- package/skills/ce-commit-push-pr/references/compose.md +29 -0
- package/skills/ce-commit-push-pr/references/context.md +42 -0
- package/skills/ce-commit-push-pr/references/gh-stack-cli.md +83 -0
- package/skills/ce-commit-push-pr/references/pr-description-writing.md +208 -0
- package/skills/ce-commit-push-pr/references/stack-submit.md +81 -0
- package/skills/ce-compound/SKILL.md +79 -0
- package/skills/ce-compound/assets/resolution-template.md +96 -0
- package/skills/ce-compound/references/agents/best-practices-researcher.md +115 -0
- package/skills/ce-compound/references/agents/data-integrity-guardian.md +68 -0
- package/skills/ce-compound/references/agents/framework-docs-researcher.md +93 -0
- package/skills/ce-compound/references/agents/pattern-recognition-specialist.md +55 -0
- package/skills/ce-compound/references/agents/performance-oracle.md +108 -0
- package/skills/ce-compound/references/agents/security-sentinel.md +91 -0
- package/skills/ce-compound/references/agents/session-historian.md +82 -0
- package/skills/ce-compound/references/assembly.md +127 -0
- package/skills/ce-compound/references/concepts-vocabulary.md +98 -0
- package/skills/ce-compound/references/enhancement.md +42 -0
- package/skills/ce-compound/references/grounding-validation.md +89 -0
- package/skills/ce-compound/references/lightweight.md +56 -0
- package/skills/ce-compound/references/modes.md +13 -0
- package/skills/ce-compound/references/refresh-and-discoverability.md +96 -0
- package/skills/ce-compound/references/report.md +115 -0
- package/skills/ce-compound/references/research.md +144 -0
- package/skills/ce-compound/references/schema.yaml +236 -0
- package/skills/ce-compound/references/session-history.md +73 -0
- package/skills/ce-compound/references/yaml-schema.md +130 -0
- package/skills/ce-compound/scripts/packs-resolve.py +737 -0
- package/skills/ce-compound/scripts/session-history/__pycache__/dsh_session.cpython-314.pyc +0 -0
- package/skills/ce-compound/scripts/session-history/discover-sessions.sh +290 -0
- package/skills/ce-compound/scripts/session-history/discover_dsh.py +123 -0
- package/skills/ce-compound/scripts/session-history/dsh_session.py +225 -0
- package/skills/ce-compound/scripts/session-history/extract-errors.py +178 -0
- package/skills/ce-compound/scripts/session-history/extract-metadata.py +301 -0
- package/skills/ce-compound/scripts/session-history/extract-skeleton.py +346 -0
- package/skills/ce-compound/scripts/validate-doc-claims.py +497 -0
- package/skills/ce-compound/scripts/validate-frontmatter.py +137 -0
- package/skills/ce-compound-refresh/SKILL.md +88 -0
- package/skills/ce-compound-refresh/assets/resolution-template.md +96 -0
- package/skills/ce-compound-refresh/references/classify.md +42 -0
- package/skills/ce-compound-refresh/references/commit.md +7 -0
- package/skills/ce-compound-refresh/references/concepts-vocabulary.md +116 -0
- package/skills/ce-compound-refresh/references/discoverability.md +15 -0
- package/skills/ce-compound-refresh/references/investigate.md +23 -0
- package/skills/ce-compound-refresh/references/modes.md +27 -0
- package/skills/ce-compound-refresh/references/per-action-flows.md +142 -0
- package/skills/ce-compound-refresh/references/report.md +23 -0
- package/skills/ce-compound-refresh/references/schema.yaml +236 -0
- package/skills/ce-compound-refresh/references/scope.md +18 -0
- package/skills/ce-compound-refresh/references/worth-audit.md +35 -0
- package/skills/ce-compound-refresh/references/yaml-schema.md +130 -0
- package/skills/ce-compound-refresh/scripts/validate-doc-claims.py +497 -0
- package/skills/ce-compound-refresh/scripts/validate-frontmatter.py +137 -0
- package/skills/ce-debug/SKILL.md +122 -0
- package/skills/ce-debug/references/anti-patterns.md +91 -0
- package/skills/ce-debug/references/defense-in-depth.md +39 -0
- package/skills/ce-debug/references/fix.md +21 -0
- package/skills/ce-debug/references/investigate.md +114 -0
- package/skills/ce-debug/references/investigation-techniques.md +384 -0
- package/skills/ce-debug/references/pipeline-mode.md +78 -0
- package/skills/ce-debug/references/post-fix-handoff.md +60 -0
- package/skills/ce-debug/references/return-to-caller.md +57 -0
- package/skills/ce-doc-review/SKILL.md +75 -0
- package/skills/ce-doc-review/references/bulk-preview.md +146 -0
- package/skills/ce-doc-review/references/cross-model-review.md +207 -0
- package/skills/ce-doc-review/references/decision-primer.md +47 -0
- package/skills/ce-doc-review/references/dispatch.md +56 -0
- package/skills/ce-doc-review/references/document-intake.md +44 -0
- package/skills/ce-doc-review/references/findings-schema.json +85 -0
- package/skills/ce-doc-review/references/modes.md +20 -0
- package/skills/ce-doc-review/references/open-questions-defer.md +158 -0
- package/skills/ce-doc-review/references/persona-selection.md +41 -0
- package/skills/ce-doc-review/references/personas/adversarial-document-reviewer.md +109 -0
- package/skills/ce-doc-review/references/personas/coherence-reviewer.md +69 -0
- package/skills/ce-doc-review/references/personas/design-lens-reviewer.md +48 -0
- package/skills/ce-doc-review/references/personas/feasibility-reviewer.md +50 -0
- package/skills/ce-doc-review/references/personas/product-lens-reviewer.md +85 -0
- package/skills/ce-doc-review/references/personas/scope-guardian-reviewer.md +83 -0
- package/skills/ce-doc-review/references/personas/security-lens-reviewer.md +41 -0
- package/skills/ce-doc-review/references/personas/whole-doc-reviewer.md +25 -0
- package/skills/ce-doc-review/references/rendering-floor.md +175 -0
- package/skills/ce-doc-review/references/review-output-template.md +123 -0
- package/skills/ce-doc-review/references/subagent-template.md +188 -0
- package/skills/ce-doc-review/references/synthesis-and-presentation.md +350 -0
- package/skills/ce-doc-review/references/walkthrough.md +347 -0
- package/skills/ce-doc-review/scripts/cross-model-doc-review.sh +1270 -0
- package/skills/ce-doc-review/scripts/packs-resolve.py +737 -0
- package/skills/ce-doc-review/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-dogfood/SKILL.md +66 -0
- package/skills/ce-dogfood/agents/openai.yaml +2 -0
- package/skills/ce-dogfood/references/dogfood-report-template.md +97 -0
- package/skills/ce-dogfood/references/phases.md +167 -0
- package/skills/ce-dogfood/references/test-matrix-taxonomy.md +62 -0
- package/skills/ce-dogfood/scripts/packs-resolve.py +737 -0
- package/skills/ce-explain/SKILL.md +81 -0
- package/skills/ce-explain/references/agents/behavior-trace-scout.md +19 -0
- package/skills/ce-explain/references/agents/work-recap-scout.md +23 -0
- package/skills/ce-explain/references/check-in.md +18 -0
- package/skills/ce-explain/references/destinations.md +35 -0
- package/skills/ce-explain/references/explainer-html.md +29 -0
- package/skills/ce-explain/references/explainer-markdown.md +24 -0
- package/skills/ce-explain/references/intake.md +50 -0
- package/skills/ce-explain/references/orchestration.md +46 -0
- package/skills/ce-handoff/SKILL.md +54 -0
- package/skills/ce-handoff/references/create.md +88 -0
- package/skills/ce-handoff/references/resume.md +46 -0
- package/skills/ce-ideate/SKILL.md +75 -0
- package/skills/ce-ideate/references/agents/issue-intelligence-analyst.md +166 -0
- package/skills/ce-ideate/references/agents/learnings-researcher.md +247 -0
- package/skills/ce-ideate/references/agents/slack-researcher.md +127 -0
- package/skills/ce-ideate/references/agents/web-researcher.md +121 -0
- package/skills/ce-ideate/references/decomposition.md +39 -0
- package/skills/ce-ideate/references/divergent-ideation.md +112 -0
- package/skills/ce-ideate/references/grounding.md +138 -0
- package/skills/ce-ideate/references/html-rendering.md +638 -0
- package/skills/ce-ideate/references/ideation-sections.md +191 -0
- package/skills/ce-ideate/references/issue-intelligence.md +44 -0
- package/skills/ce-ideate/references/markdown-rendering.md +242 -0
- package/skills/ce-ideate/references/output-mode.md +34 -0
- package/skills/ce-ideate/references/post-ideation-workflow.md +169 -0
- package/skills/ce-ideate/references/scope-gates.md +148 -0
- package/skills/ce-ideate/references/universal-ideation.md +119 -0
- package/skills/ce-ideate/references/user-research-artifacts.md +28 -0
- package/skills/ce-ideate/references/web-research-cache.md +56 -0
- package/skills/ce-noslop/SKILL.md +45 -0
- package/skills/ce-noslop/references/patterns.md +61 -0
- package/skills/ce-noslop/references/terminology.md +3 -0
- package/skills/ce-optimize/SKILL.md +64 -0
- package/skills/ce-optimize/references/agents/learnings-researcher.md +247 -0
- package/skills/ce-optimize/references/agents/repo-research-analyst.md +258 -0
- package/skills/ce-optimize/references/example-expensive-benchmark-spec.yaml +88 -0
- package/skills/ce-optimize/references/example-hard-spec.yaml +64 -0
- package/skills/ce-optimize/references/example-judge-spec.yaml +83 -0
- package/skills/ce-optimize/references/example-text-target-spec.yaml +80 -0
- package/skills/ce-optimize/references/experiment-log-schema.yaml +563 -0
- package/skills/ce-optimize/references/experiment-prompt-template.md +143 -0
- package/skills/ce-optimize/references/judge-prompt-template.md +117 -0
- package/skills/ce-optimize/references/loop.md +286 -0
- package/skills/ce-optimize/references/measurement.md +168 -0
- package/skills/ce-optimize/references/optimize-spec-schema.yaml +570 -0
- package/skills/ce-optimize/references/persistence.md +93 -0
- package/skills/ce-optimize/references/spec.md +107 -0
- package/skills/ce-optimize/references/text-targets.md +51 -0
- package/skills/ce-optimize/references/usage-guide.md +147 -0
- package/skills/ce-optimize/references/wrap-up.md +54 -0
- package/skills/ce-optimize/scripts/decide.mjs +676 -0
- package/skills/ce-optimize/scripts/experiment-worktree.sh +330 -0
- package/skills/ce-optimize/scripts/measure.sh +140 -0
- package/skills/ce-optimize/scripts/parallel-probe.sh +134 -0
- package/skills/ce-plan/SKILL.md +60 -0
- package/skills/ce-plan/references/agents/agent-native-planning-strategist.md +66 -0
- package/skills/ce-plan/references/agents/architecture-strategist.md +46 -0
- package/skills/ce-plan/references/agents/best-practices-researcher.md +118 -0
- package/skills/ce-plan/references/agents/data-integrity-guardian.md +68 -0
- package/skills/ce-plan/references/agents/framework-docs-researcher.md +97 -0
- package/skills/ce-plan/references/agents/git-history-analyzer.md +40 -0
- package/skills/ce-plan/references/agents/learnings-researcher.md +268 -0
- package/skills/ce-plan/references/agents/pattern-recognition-specialist.md +55 -0
- package/skills/ce-plan/references/agents/performance-oracle.md +108 -0
- package/skills/ce-plan/references/agents/repo-research-analyst.md +262 -0
- package/skills/ce-plan/references/agents/security-sentinel.md +91 -0
- package/skills/ce-plan/references/agents/slack-researcher.md +131 -0
- package/skills/ce-plan/references/agents/spec-flow-analyzer.md +84 -0
- package/skills/ce-plan/references/agents/web-researcher.md +125 -0
- package/skills/ce-plan/references/approach-altitude.md +55 -0
- package/skills/ce-plan/references/bakeoff.md +9 -0
- package/skills/ce-plan/references/deepening-workflow.md +263 -0
- package/skills/ce-plan/references/final-review.md +134 -0
- package/skills/ce-plan/references/html-rendering.md +638 -0
- package/skills/ce-plan/references/intake.md +158 -0
- package/skills/ce-plan/references/markdown-rendering.md +242 -0
- package/skills/ce-plan/references/output-contracts.md +29 -0
- package/skills/ce-plan/references/output-mode.md +45 -0
- package/skills/ce-plan/references/plan-handoff.md +116 -0
- package/skills/ce-plan/references/plan-sections.md +489 -0
- package/skills/ce-plan/references/reasoning-elevation.md +124 -0
- package/skills/ce-plan/references/research.md +211 -0
- package/skills/ce-plan/references/resume.md +61 -0
- package/skills/ce-plan/references/settled-decisions.md +45 -0
- package/skills/ce-plan/references/structure.md +207 -0
- package/skills/ce-plan/references/synthesis-summary.md +260 -0
- package/skills/ce-plan/references/universal-planning.md +170 -0
- package/skills/ce-plan/scripts/elevation-dispatch.sh +288 -0
- package/skills/ce-plan/scripts/packs-resolve.py +737 -0
- package/skills/ce-plan/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-polish/SKILL.md +22 -0
- package/skills/ce-polish/agents/openai.yaml +2 -0
- package/skills/ce-polish/references/dev-server-astro.md +58 -0
- package/skills/ce-polish/references/dev-server-detection.md +40 -0
- package/skills/ce-polish/references/dev-server-next.md +61 -0
- package/skills/ce-polish/references/dev-server-nuxt.md +58 -0
- package/skills/ce-polish/references/dev-server-procfile.md +59 -0
- package/skills/ce-polish/references/dev-server-rails.md +50 -0
- package/skills/ce-polish/references/dev-server-remix.md +58 -0
- package/skills/ce-polish/references/dev-server-sveltekit.md +58 -0
- package/skills/ce-polish/references/dev-server-vite.md +48 -0
- package/skills/ce-polish/references/install-riffrec.md +35 -0
- package/skills/ce-polish/references/launch-json-schema.md +177 -0
- package/skills/ce-polish/references/live-loop.md +98 -0
- package/skills/ce-polish/references/live-remote.md +35 -0
- package/skills/ce-polish/references/live-start.md +93 -0
- package/skills/ce-polish/references/live-stream-contract.md +253 -0
- package/skills/ce-polish/references/run.md +67 -0
- package/skills/ce-polish/scripts/detect-project-type.sh +277 -0
- package/skills/ce-polish/scripts/detect-riffrec.sh +225 -0
- package/skills/ce-polish/scripts/live-endpoint.js +2841 -0
- package/skills/ce-polish/scripts/read-launch-json.sh +117 -0
- package/skills/ce-polish/scripts/resolve-package-manager.sh +95 -0
- package/skills/ce-polish/scripts/resolve-port.sh +307 -0
- package/skills/ce-pov/SKILL.md +64 -0
- package/skills/ce-pov/references/agents/external-evidence-researcher.md +26 -0
- package/skills/ce-pov/references/agents/pov-peer.md +43 -0
- package/skills/ce-pov/references/agents/precedent-activity-scout.md +21 -0
- package/skills/ce-pov/references/agents/project-grounding-scout.md +32 -0
- package/skills/ce-pov/references/boundaries.md +36 -0
- package/skills/ce-pov/references/cross-model-panel.md +596 -0
- package/skills/ce-pov/references/followup.md +9 -0
- package/skills/ce-pov/references/grounding.md +47 -0
- package/skills/ce-pov/references/intake.md +61 -0
- package/skills/ce-pov/references/invocation.md +47 -0
- package/skills/ce-pov/references/method.md +68 -0
- package/skills/ce-pov/references/pov-schema.json +56 -0
- package/skills/ce-pov/references/report.md +7 -0
- package/skills/ce-pov/scripts/cross-model-pov.sh +1110 -0
- package/skills/ce-pov/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-product-pulse/SKILL.md +81 -0
- package/skills/ce-product-pulse/agents/openai.yaml +2 -0
- package/skills/ce-product-pulse/references/config.md +20 -0
- package/skills/ce-product-pulse/references/interview.md +260 -0
- package/skills/ce-product-pulse/references/report-template.md +89 -0
- package/skills/ce-product-pulse/references/run.md +46 -0
- package/skills/ce-product-pulse/references/setup.md +43 -0
- package/skills/ce-promote/SKILL.md +90 -0
- package/skills/ce-promote/agents/openai.yaml +2 -0
- package/skills/ce-promote/references/spiral-cli.md +149 -0
- package/skills/ce-proof/SKILL.md +65 -0
- package/skills/ce-proof/references/api.md +176 -0
- package/skills/ce-proof/references/workflows.md +124 -0
- package/skills/ce-prototype/SKILL.md +59 -0
- package/skills/ce-prototype/assets/annotate.css +206 -0
- package/skills/ce-prototype/assets/annotate.js +837 -0
- package/skills/ce-prototype/references/annotation-loop.md +32 -0
- package/skills/ce-prototype/references/build.md +39 -0
- package/skills/ce-prototype/references/craft-floor.md +43 -0
- package/skills/ce-prototype/references/preview.md +118 -0
- package/skills/ce-prototype/references/scoping.md +38 -0
- package/skills/ce-prototype/references/write-back.md +37 -0
- package/skills/ce-prototype/scripts/light-webserver.js +1275 -0
- package/skills/ce-resolve-pr-feedback/SKILL.md +60 -0
- package/skills/ce-resolve-pr-feedback/references/agents/pr-comment-resolver.md +59 -0
- package/skills/ce-resolve-pr-feedback/references/evaluation-rubric.md +141 -0
- package/skills/ce-resolve-pr-feedback/references/full-mode.md +336 -0
- package/skills/ce-resolve-pr-feedback/references/pipeline-mode.md +23 -0
- package/skills/ce-resolve-pr-feedback/references/resume.md +60 -0
- package/skills/ce-resolve-pr-feedback/references/return-to-caller.md +111 -0
- package/skills/ce-resolve-pr-feedback/references/targeted-mode.md +48 -0
- package/skills/ce-resolve-pr-feedback/scripts/get-pr-comments +176 -0
- package/skills/ce-resolve-pr-feedback/scripts/get-thread-for-comment +84 -0
- package/skills/ce-resolve-pr-feedback/scripts/pending-feedback.py +287 -0
- package/skills/ce-resolve-pr-feedback/scripts/reply-to-pr-thread +57 -0
- package/skills/ce-resolve-pr-feedback/scripts/resolve-pr-thread +23 -0
- package/skills/ce-retune/SKILL.md +42 -0
- package/skills/ce-retune/agents/openai.yaml +2 -0
- package/skills/ce-retune/references/baseline-mining.md +122 -0
- package/skills/ce-retune/references/corpus-audit.md +128 -0
- package/skills/ce-retune/references/cut-passes.md +129 -0
- package/skills/ce-retune/references/halt-taxonomy.md +123 -0
- package/skills/ce-retune/references/noise-floor.md +131 -0
- package/skills/ce-retune/references/workflow-shapes.md +73 -0
- package/skills/ce-riffrec-feedback-analysis/SKILL.md +33 -0
- package/skills/ce-riffrec-feedback-analysis/references/analyzer.md +18 -0
- package/skills/ce-riffrec-feedback-analysis/references/compound-engineering-feedback-format.md +116 -0
- package/skills/ce-riffrec-feedback-analysis/references/extensive-analysis.md +98 -0
- package/skills/ce-riffrec-feedback-analysis/references/install-riffrec.md +27 -0
- package/skills/ce-riffrec-feedback-analysis/references/quick-bug-report.md +38 -0
- package/skills/ce-riffrec-feedback-analysis/scripts/analyze_riffrec_zip.py +1306 -0
- package/skills/ce-setup/SKILL.md +103 -0
- package/skills/ce-setup/agents/openai.yaml +2 -0
- package/skills/ce-setup/assets/compounding-directive.md +11 -0
- package/skills/ce-setup/assets/noslop-directive.md +7 -0
- package/skills/ce-setup/assets/pack-rule-template.md +11 -0
- package/skills/ce-setup/references/acpx-cache-warm.md +14 -0
- package/skills/ce-setup/references/config-template.yaml +210 -0
- package/skills/ce-setup/references/legacy-codex-tool-map.md +15 -0
- package/skills/ce-setup/references/pack-scaffold.md +45 -0
- package/skills/ce-setup/references/repo-fixes.md +121 -0
- package/skills/ce-setup/scripts/check-health +929 -0
- package/skills/ce-setup/scripts/packs-resolve.py +737 -0
- package/skills/ce-simplify-code/SKILL.md +76 -0
- package/skills/ce-simplify-code/references/personas/code-quality-reviewer.md +17 -0
- package/skills/ce-simplify-code/references/personas/code-reuse-reviewer.md +7 -0
- package/skills/ce-simplify-code/references/personas/comment-reviewer.md +24 -0
- package/skills/ce-simplify-code/references/personas/efficiency-reviewer.md +11 -0
- package/skills/ce-simplify-code/references/personas/test-value-reviewer.md +23 -0
- package/skills/ce-simplify-code/references/reviewer-dispatch.md +11 -0
- package/skills/ce-simplify-code/references/standalone-report.md +25 -0
- package/skills/ce-strategy/SKILL.md +61 -0
- package/skills/ce-strategy/references/grounding.md +22 -0
- package/skills/ce-strategy/references/interview.md +179 -0
- package/skills/ce-strategy/references/strategy-template.md +94 -0
- package/skills/ce-strategy/references/update-run.md +33 -0
- package/skills/ce-sweep/SKILL.md +93 -0
- package/skills/ce-sweep/agents/openai.yaml +2 -0
- package/skills/ce-sweep/references/agents/media-analyzer.md +54 -0
- package/skills/ce-sweep/references/interview.md +208 -0
- package/skills/ce-sweep/references/model-tiers.md +13 -0
- package/skills/ce-sweep/references/plan-template.md +62 -0
- package/skills/ce-sweep/references/run.md +126 -0
- package/skills/ce-sweep/references/sources/email.md +57 -0
- package/skills/ce-sweep/references/sources/github-issues.md +59 -0
- package/skills/ce-sweep/references/sources/slack.md +61 -0
- package/skills/ce-sweep/references/state-schema.md +157 -0
- package/skills/ce-sweep/references/subagent-template.md +60 -0
- package/skills/ce-sweep/scripts/analyze_riffrec_zip.py +1306 -0
- package/skills/ce-sweep/scripts/sweep-state.py +793 -0
- package/skills/ce-test-browser/SKILL.md +50 -0
- package/skills/ce-test-browser/references/agent-browser-driver.md +47 -0
- package/skills/ce-test-browser/references/pipeline-orchestration.md +45 -0
- package/skills/ce-test-browser/references/route-and-report.md +129 -0
- package/skills/ce-test-browser/scripts/resolve-port.sh +41 -0
- package/skills/ce-test-xcode/SKILL.md +26 -0
- package/skills/ce-test-xcode/agents/openai.yaml +2 -0
- package/skills/ce-test-xcode/references/setup-and-build.md +32 -0
- package/skills/ce-test-xcode/references/test-and-report.md +64 -0
- package/skills/ce-work/SKILL.md +62 -0
- package/skills/ce-work/references/agents/figma-design-sync.md +27 -0
- package/skills/ce-work/references/agents/implementation-worker.md +17 -0
- package/skills/ce-work/references/cross-model-execution.md +191 -0
- package/skills/ce-work/references/execution-engines.md +64 -0
- package/skills/ce-work/references/execution-strategy.md +73 -0
- package/skills/ce-work/references/implementation-loop.md +107 -0
- package/skills/ce-work/references/implementation-result-schema.json +52 -0
- package/skills/ce-work/references/input-triage.md +50 -0
- package/skills/ce-work/references/non-code-execution.md +23 -0
- package/skills/ce-work/references/return-to-caller.md +33 -0
- package/skills/ce-work/references/review-findings-followup.md +82 -0
- package/skills/ce-work/references/shipping-workflow.md +89 -0
- package/skills/ce-work/references/tracker-defer.md +150 -0
- package/skills/ce-work/references/work-intake.md +51 -0
- package/skills/ce-work/references/workspace-setup.md +27 -0
- package/skills/ce-work/scripts/__pycache__/unit_workspace_jobs.cpython-314.pyc +0 -0
- package/skills/ce-work/scripts/__pycache__/unit_workspace_state.cpython-314.pyc +0 -0
- package/skills/ce-work/scripts/cross-model-work.sh +1385 -0
- package/skills/ce-work/scripts/peer-job-runner.py +2594 -0
- package/skills/ce-work/scripts/unit-workspace.py +215 -0
- package/skills/ce-work/scripts/unit_workspace_ignored.py +93 -0
- package/skills/ce-work/scripts/unit_workspace_integration.py +677 -0
- package/skills/ce-work/scripts/unit_workspace_jobs.py +1216 -0
- package/skills/ce-work/scripts/unit_workspace_lifecycle.py +981 -0
- package/skills/ce-work/scripts/unit_workspace_state.py +1379 -0
- package/skills/ce-work/scripts/unit_workspace_transaction.py +612 -0
- package/skills/ce-worktree/SKILL.md +54 -0
- package/skills/lfg/SKILL.md +59 -0
- package/skills/lfg/references/debug-return.md +25 -0
- package/skills/lfg/references/intake.md +30 -0
- package/skills/lfg/references/next-work-handoff.md +96 -0
- package/skills/lfg/references/plan-brief.md +36 -0
- package/skills/lfg/references/review-followup.md +78 -0
- package/skills/lfg/references/shipping.md +65 -0
- package/skills/lfg/references/stage-routing.md +49 -0
- package/skills/lfg/references/task-visibility.md +11 -0
- package/skills/lfg/references/tracker-defer.md +150 -0
- package/skills/lfg/references/work-return.md +27 -0
- package/skills/wtf/SKILL.md +33 -0
- package/skills/wtf/agents/openai.yaml +2 -0
|
@@ -0,0 +1,3656 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic snapshot + state helper for ce-babysit-pr.
|
|
3
|
+
|
|
4
|
+
The agent (SKILL.md) owns judgment and mutations; this script owns the parts
|
|
5
|
+
prose cannot do reliably: a combined fetch of both event streams, atomic state
|
|
6
|
+
read/write under a file lock, and dedup keyed on remote truth.
|
|
7
|
+
|
|
8
|
+
The dedup model is claim -> act -> confirm. `snapshot` never marks an item
|
|
9
|
+
handled just because it observed it; an item stays actionable until either the
|
|
10
|
+
agent confirms it acted (`mark`) OR remote truth removes it (a resolved thread
|
|
11
|
+
drops out of the unresolved fetch). So if a resolve/debug pass crashes or fails
|
|
12
|
+
before the agent marks it, the item is still actionable on the next tick.
|
|
13
|
+
|
|
14
|
+
Subcommands:
|
|
15
|
+
snapshot --pr N [--repo O/R] --state-dir DIR [--fetch-file F]
|
|
16
|
+
Fetch (or load F), diff against on-disk state, persist the
|
|
17
|
+
observed state atomically, emit the actionable set as JSON.
|
|
18
|
+
mark --state-dir DIR --invocation-id ID --session-started-at TIME
|
|
19
|
+
--invocation-budget-seconds N (--thread ID --disposition open|dispatched
|
|
20
|
+
| --comment ID --disposition open|dispatched | --check KEY
|
|
21
|
+
| --disposition needs-human --residual-file FILE
|
|
22
|
+
| --answer-decision ID --answer-file FILE)
|
|
23
|
+
A residual-only needs-human mark atomically freezes every source observation
|
|
24
|
+
in FILE under one decision. Sources retain their ordinary dispositions.
|
|
25
|
+
Record that the agent acted on an item. A `dispatched` thread is
|
|
26
|
+
re-emitted when a later reviewer comment moves its last-comment
|
|
27
|
+
identity past the one we acted on (our own reply does not
|
|
28
|
+
re-trigger). A non-thread feedback item never drops out of
|
|
29
|
+
the fetch on its own, so `--comment` is the only way to silence a
|
|
30
|
+
handled one. A new head SHA clears dispatched CI checks.
|
|
31
|
+
|
|
32
|
+
--fetch-file injects a pre-captured combined snapshot instead of calling gh,
|
|
33
|
+
so the diff logic is testable without a live PR.
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import errno
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import signal
|
|
42
|
+
import subprocess
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
import uuid
|
|
48
|
+
from contextlib import contextmanager
|
|
49
|
+
from datetime import datetime, timedelta, timezone
|
|
50
|
+
from urllib.parse import quote, urlsplit
|
|
51
|
+
|
|
52
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
53
|
+
|
|
54
|
+
if IS_WINDOWS:
|
|
55
|
+
import msvcrt
|
|
56
|
+
else:
|
|
57
|
+
import fcntl
|
|
58
|
+
|
|
59
|
+
# Conclusions that mean "this check needs attention" (failing states).
|
|
60
|
+
FAILING = {"FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"}
|
|
61
|
+
TERMINAL_CHECK_CONCLUSIONS = {
|
|
62
|
+
"ACTION_REQUIRED", "CANCELLED", "FAILURE", "NEUTRAL", "SKIPPED", "STALE",
|
|
63
|
+
"STARTUP_FAILURE", "SUCCESS", "TIMED_OUT",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
DISPOSITION_OPEN = "open"
|
|
67
|
+
DISPOSITION_NEEDS_HUMAN = "needs-human"
|
|
68
|
+
DISPOSITION_DISPATCHED = "dispatched"
|
|
69
|
+
NEEDS_HUMAN_SOURCE_KINDS = {"thread", "comment", "review", "check", "currency"}
|
|
70
|
+
CURRENCY_CLAIMED = "claimed"
|
|
71
|
+
CURRENCY_CONFIRMED = "confirmed"
|
|
72
|
+
CURRENCY_OUTCOME_MUTATION_OBSERVED = "mutation-observed"
|
|
73
|
+
CURRENCY_OUTCOME_PROVEN_NO_MUTATION = "proven-no-mutation"
|
|
74
|
+
CURRENCY_OUTCOME_AMBIGUOUS = "ambiguous"
|
|
75
|
+
CURRENCY_ATTENTION_CLAIM = "claim"
|
|
76
|
+
CURRENCY_ATTENTION_DECIDE = "decide"
|
|
77
|
+
CURRENCY_ATTENTION_INSPECT = "inspect"
|
|
78
|
+
CURRENCY_ATTENTION_RECONCILE = "reconcile"
|
|
79
|
+
|
|
80
|
+
MANAGER_CONFIRMED = "confirmed"
|
|
81
|
+
MANAGER_ABSENT = "absent"
|
|
82
|
+
MANAGER_PROBE_ERROR = "probe-error"
|
|
83
|
+
RELATIONSHIP_DEPENDENT = "dependent"
|
|
84
|
+
RELATIONSHIP_INDEPENDENT = "independent"
|
|
85
|
+
RELATIONSHIP_PROBE_ERROR = "probe-error"
|
|
86
|
+
BASE_REF_CURRENT = "current"
|
|
87
|
+
BASE_REF_RACE = "race"
|
|
88
|
+
BASE_REF_STALE_COMPUTATION = "stale-computation"
|
|
89
|
+
# GitHub can leave potentialMergeCommit parented on the old base indefinitely after main moves
|
|
90
|
+
# (observed 20+ min on tmchow/pr-stack-test, unrefreshed by polls or a body PATCH). Once the live
|
|
91
|
+
# base is stable this long with the same head, the cached CLEAN/DIRTY verdict is accepted with a
|
|
92
|
+
# disclosure instead of blocking readiness forever.
|
|
93
|
+
STALE_MERGE_COMPUTATION_SECONDS = 600
|
|
94
|
+
BASE_REF_MERGEABILITY_PENDING = "mergeability-pending"
|
|
95
|
+
BASE_REF_PROBE_ERROR = "probe-error"
|
|
96
|
+
BASE_REF_LEGACY_STALE = "stale"
|
|
97
|
+
|
|
98
|
+
# Quiet window a merge-ready wake ordinarily requires. The agent widens it by re-arming when it
|
|
99
|
+
# judges more waiting is warranted; the engine never adjusts the window it was given.
|
|
100
|
+
DEFAULT_SETTLE_SECONDS = 300.0
|
|
101
|
+
|
|
102
|
+
# Quiet window before a comment-only wake. Every non-empty top-level body is a candidate the
|
|
103
|
+
# resolver must classify, so waking on the first one spends a full ce-resolve-pr-feedback dispatch
|
|
104
|
+
# to conclude "status noise". Only this reason waits; real work never consults the clock.
|
|
105
|
+
FEEDBACK_COALESCE_SECONDS = 180
|
|
106
|
+
|
|
107
|
+
# Trajectory check-history states (persisted, compared by ==).
|
|
108
|
+
CHECK_UNKNOWN = "unknown"
|
|
109
|
+
CHECK_CLEAR = "clear"
|
|
110
|
+
CHECK_FAILING = "failing"
|
|
111
|
+
# Trajectory single-stream activity labels.
|
|
112
|
+
STREAM_CI = "ci"
|
|
113
|
+
STREAM_REVIEW = "review"
|
|
114
|
+
# Keep a check's recurrence memory across a transient absence (a one-tick gap from a
|
|
115
|
+
# workflow-registration lag or a paths-filtered run), but bound growth: evict entries
|
|
116
|
+
# unseen for this many ticks.
|
|
117
|
+
CHECK_HISTORY_TTL = 30
|
|
118
|
+
|
|
119
|
+
# One invocation is a bounded monitoring shift, not the lifetime of the PR. Eight hours covers a
|
|
120
|
+
# maximum-length GitHub-hosted Actions job (six hours) plus reaction and settle time. Callers may
|
|
121
|
+
# choose another fixed value on the first snapshot; later commands must present the same value.
|
|
122
|
+
DEFAULT_INVOCATION_BUDGET_SECONDS = 8 * 60 * 60
|
|
123
|
+
# The 8h budget is spent in *active watch-capability time*, not raw wall-clock: a span where the
|
|
124
|
+
# whole watch process was suspended (laptop asleep) is excluded. Detection is coarse — an activity
|
|
125
|
+
# gap wider than this threshold (well above the 150s poll interval) is charged to dead time, minus
|
|
126
|
+
# the threshold itself so ordinary polls/ticks never register. The 3-day backstop stays wall-clock.
|
|
127
|
+
DEAD_TIME_THRESHOLD_SECONDS = 15 * 60
|
|
128
|
+
DEFAULT_INVOCATION_BACKSTOP_SECONDS = 3 * 24 * 60 * 60
|
|
129
|
+
CURRENCY_RETRY_BACKOFF_SECONDS = 30
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --- cross-platform state primitives ------------------------------------------
|
|
133
|
+
#
|
|
134
|
+
# The watcher needs three POSIX facilities that native Windows Python does not
|
|
135
|
+
# have: an advisory file lock (fcntl.flock), a process-identity probe for the
|
|
136
|
+
# PID-reuse guard (`ps -o lstart=`), and an unconditional rename. Each Windows
|
|
137
|
+
# analog differs in a way that matters here, so each is named rather than
|
|
138
|
+
# translated literally. See
|
|
139
|
+
# docs/solutions/architecture-patterns/posix-process-supervision-on-native-windows.md.
|
|
140
|
+
|
|
141
|
+
# Poll spacing while waiting on a Windows lock or a blocked rename.
|
|
142
|
+
_WIN_RETRY_SECONDS = 0.05
|
|
143
|
+
# Bound only the rename retry. A lock wait is unbounded, matching flock.
|
|
144
|
+
_WIN_REPLACE_TIMEOUT_SECONDS = 10.0
|
|
145
|
+
|
|
146
|
+
if IS_WINDOWS:
|
|
147
|
+
import ctypes
|
|
148
|
+
from ctypes import wintypes
|
|
149
|
+
|
|
150
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
151
|
+
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
152
|
+
|
|
153
|
+
# Declare argtypes/restype explicitly: without them ctypes truncates HANDLEs
|
|
154
|
+
# to 32-bit ints on Win64, so a valid handle silently becomes a bad one.
|
|
155
|
+
_kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
156
|
+
_kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
157
|
+
_kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
158
|
+
_kernel32.CloseHandle.restype = wintypes.BOOL
|
|
159
|
+
_kernel32.GetProcessTimes.argtypes = [
|
|
160
|
+
wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME),
|
|
161
|
+
ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME),
|
|
162
|
+
ctypes.POINTER(wintypes.FILETIME)]
|
|
163
|
+
_kernel32.GetProcessTimes.restype = wintypes.BOOL
|
|
164
|
+
_kernel32.QueryFullProcessImageNameW.argtypes = [
|
|
165
|
+
wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR,
|
|
166
|
+
ctypes.POINTER(wintypes.DWORD)]
|
|
167
|
+
_kernel32.QueryFullProcessImageNameW.restype = wintypes.BOOL
|
|
168
|
+
|
|
169
|
+
def _win_process_identity(pid):
|
|
170
|
+
"""Creation time plus image path — the Windows analog of `ps -o lstart= -o command=`.
|
|
171
|
+
|
|
172
|
+
Creation time is the part that makes this a PID-reuse guard: Windows recycles PIDs
|
|
173
|
+
aggressively, and a recycled PID always carries a later creation time than the watcher
|
|
174
|
+
we recorded. Returns None when the process is gone or unopenable, which the caller
|
|
175
|
+
treats as "identity not proven" and declines to act on."""
|
|
176
|
+
handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
177
|
+
if not handle:
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
created, exited, kernel, user = (wintypes.FILETIME() for _ in range(4))
|
|
181
|
+
if not _kernel32.GetProcessTimes(
|
|
182
|
+
handle, ctypes.byref(created), ctypes.byref(exited),
|
|
183
|
+
ctypes.byref(kernel), ctypes.byref(user)):
|
|
184
|
+
return None
|
|
185
|
+
started = (created.dwHighDateTime << 32) | created.dwLowDateTime
|
|
186
|
+
if not started:
|
|
187
|
+
return None
|
|
188
|
+
size = wintypes.DWORD(32768)
|
|
189
|
+
buf = ctypes.create_unicode_buffer(size.value)
|
|
190
|
+
image = (buf.value if _kernel32.QueryFullProcessImageNameW(
|
|
191
|
+
handle, 0, buf, ctypes.byref(size)) else "")
|
|
192
|
+
finally:
|
|
193
|
+
_kernel32.CloseHandle(handle)
|
|
194
|
+
return "{} {}".format(started, image)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _lock_acquire(fd, exclusive):
|
|
198
|
+
"""Block until this process owns the lock file.
|
|
199
|
+
|
|
200
|
+
msvcrt.locking is the Windows analog of flock, with three differences. It locks a byte range
|
|
201
|
+
from the current offset rather than the whole file, so the offset is pinned to 0. It has no
|
|
202
|
+
shared mode, so a reader takes the same exclusive lock a writer does — free here, because every
|
|
203
|
+
critical section is a small local read/write with the network fetch already completed outside
|
|
204
|
+
the lock. And its blocking mode (LK_LOCK) gives up after roughly ten seconds instead of waiting,
|
|
205
|
+
so retrying the non-blocking mode is what actually reproduces flock's wait-for-the-holder
|
|
206
|
+
behavior. As on POSIX, the OS releases the lock if a holder dies, so a crash cannot wedge this."""
|
|
207
|
+
if not IS_WINDOWS:
|
|
208
|
+
fcntl.flock(fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
|
209
|
+
return
|
|
210
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
211
|
+
while True:
|
|
212
|
+
try:
|
|
213
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
214
|
+
return
|
|
215
|
+
except OSError as e:
|
|
216
|
+
# Retry only genuine contention. msvcrt reports a held range as EACCES; anything
|
|
217
|
+
# else (EBADF from a closed descriptor, EINVAL from a bad range) is a defect that
|
|
218
|
+
# retrying cannot clear, and swallowing it here would spin this loop forever with
|
|
219
|
+
# no output — a silent hang, which for a watcher is indistinguishable from the
|
|
220
|
+
# "monitoring is quietly not running" failure this port exists to fix.
|
|
221
|
+
if e.errno != errno.EACCES:
|
|
222
|
+
raise
|
|
223
|
+
time.sleep(_WIN_RETRY_SECONDS)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _lock_release(fd):
|
|
227
|
+
if not IS_WINDOWS:
|
|
228
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
229
|
+
return
|
|
230
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
231
|
+
try:
|
|
232
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
233
|
+
except OSError:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@contextmanager
|
|
238
|
+
def _state_lock(state_dir, exclusive=True):
|
|
239
|
+
"""Serialize access to the state dir for the duration of the body."""
|
|
240
|
+
os.makedirs(state_dir, exist_ok=True)
|
|
241
|
+
# O_RDWR|O_CREAT rather than a truncating open("w"): on Windows, truncating a file another
|
|
242
|
+
# process holds a byte-range lock on fails, which would make lock acquisition itself the race.
|
|
243
|
+
fd = os.open(os.path.join(state_dir, "lock"), os.O_RDWR | os.O_CREAT, 0o600)
|
|
244
|
+
try:
|
|
245
|
+
_lock_acquire(fd, exclusive)
|
|
246
|
+
try:
|
|
247
|
+
yield
|
|
248
|
+
finally:
|
|
249
|
+
_lock_release(fd)
|
|
250
|
+
finally:
|
|
251
|
+
os.close(fd)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _replace_atomic(src, dst):
|
|
255
|
+
"""Atomically move `src` onto `dst`.
|
|
256
|
+
|
|
257
|
+
POSIX rename always succeeds over an open destination. Windows refuses while any other handle
|
|
258
|
+
to `dst` is open, so an unlocked best-effort reader — or a virus scanner that opened the file
|
|
259
|
+
behind us — would otherwise turn a routine concurrent read into a crashed writer. Retry briefly
|
|
260
|
+
rather than treating that transient sharing violation as a failure."""
|
|
261
|
+
if not IS_WINDOWS:
|
|
262
|
+
os.replace(src, dst)
|
|
263
|
+
return
|
|
264
|
+
deadline = time.monotonic() + _WIN_REPLACE_TIMEOUT_SECONDS
|
|
265
|
+
while True:
|
|
266
|
+
try:
|
|
267
|
+
os.replace(src, dst)
|
|
268
|
+
return
|
|
269
|
+
except PermissionError:
|
|
270
|
+
if time.monotonic() >= deadline:
|
|
271
|
+
raise
|
|
272
|
+
time.sleep(_WIN_RETRY_SECONDS)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class _WatchSuperseded(Exception):
|
|
276
|
+
"""Internal control flow: this watch lost ownership and must unwind immediately."""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class _InvocationSuperseded(Exception):
|
|
280
|
+
"""Internal control flow: this watch belongs to an invocation that was replaced."""
|
|
281
|
+
|
|
282
|
+
def __init__(self, current_invocation_id):
|
|
283
|
+
self.current_invocation_id = current_invocation_id
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _now():
|
|
287
|
+
return datetime.now(timezone.utc)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _iso(dt):
|
|
291
|
+
return dt.isoformat()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _run(cmd):
|
|
295
|
+
# gh honors CLICOLOR_FORCE / GH_FORCE_TTY even for --json output, and a host that exports them
|
|
296
|
+
# (observed under orca) makes every JSON read unparsable. Pin plain output regardless of caller env.
|
|
297
|
+
env = os.environ.copy()
|
|
298
|
+
env["NO_COLOR"] = "1"
|
|
299
|
+
env.pop("CLICOLOR_FORCE", None)
|
|
300
|
+
env.pop("GH_FORCE_TTY", None)
|
|
301
|
+
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", env=env)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _run_git(cmd):
|
|
305
|
+
env = os.environ.copy()
|
|
306
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
307
|
+
env["GCM_INTERACTIVE"] = "never"
|
|
308
|
+
env["GIT_ASKPASS"] = ""
|
|
309
|
+
env["SSH_ASKPASS"] = ""
|
|
310
|
+
return subprocess.run(
|
|
311
|
+
cmd, capture_output=True, text=True, encoding="utf-8", env=env, timeout=30,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _run_checked(cmd, label):
|
|
316
|
+
r = _run(cmd)
|
|
317
|
+
if r.returncode != 0:
|
|
318
|
+
raise SystemExit(f"{label} failed: {r.stderr.strip()}")
|
|
319
|
+
return r
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _split_repo(repo):
|
|
323
|
+
"""Parse "[HOST/]OWNER/NAME" into (owner, name), or (None, None). A host-qualified ref (gh's
|
|
324
|
+
documented `[HOST/]OWNER/REPO` selector) drops the host — the last two segments are what the
|
|
325
|
+
GraphQL lookup needs; treating the host as the owner would query a nonexistent repo on GHE."""
|
|
326
|
+
if not repo:
|
|
327
|
+
return None, None
|
|
328
|
+
parts = repo.strip("/").split("/")
|
|
329
|
+
if len(parts) >= 2:
|
|
330
|
+
return parts[-2], parts[-1]
|
|
331
|
+
return None, None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _resolve_repo_ref(repo, url):
|
|
335
|
+
"""Resolve (owner, name, host) from --repo + the PR url, else one `gh repo view` call.
|
|
336
|
+
The host is parsed from the url and threaded into every `gh api` call so a GitHub Enterprise
|
|
337
|
+
PR queries the right host — without it, `gh api` defaults to github.com and a GHE babysitter
|
|
338
|
+
fetches the PR via `gh pr view` but then reads review threads / workflow runs from github.com.
|
|
339
|
+
Parsing the url (already fetched by `fetch`) also avoids a redundant `gh repo view` per tick."""
|
|
340
|
+
owner, name = _split_repo(repo)
|
|
341
|
+
host = None
|
|
342
|
+
if url:
|
|
343
|
+
# https://<host>/OWNER/NAME/pull/N
|
|
344
|
+
parts = url.rstrip("/").split("/")
|
|
345
|
+
if len(parts) >= 5 and parts[0].startswith("http"):
|
|
346
|
+
host = parts[2]
|
|
347
|
+
if not owner:
|
|
348
|
+
owner, name = parts[-4], parts[-3]
|
|
349
|
+
if not owner:
|
|
350
|
+
r = _run(["gh", "repo", "view", "--json", "owner,name"])
|
|
351
|
+
if r.returncode == 0:
|
|
352
|
+
info = json.loads(r.stdout)
|
|
353
|
+
owner, name = info.get("owner", {}).get("login"), info.get("name")
|
|
354
|
+
if not owner or not name:
|
|
355
|
+
raise SystemExit("could not resolve owner/repo; pass --repo OWNER/REPO")
|
|
356
|
+
return owner, name, host
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _host_args(host):
|
|
360
|
+
"""`gh api --hostname` selector so GHE calls hit the PR's host, not the default github.com."""
|
|
361
|
+
return ["--hostname", host] if host else []
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _api_json(path, host=None):
|
|
365
|
+
"""Best-effort JSON API read. An incomplete corroboration must stay pending."""
|
|
366
|
+
result = _run(["gh", "api", *_host_args(host), path])
|
|
367
|
+
if result.returncode != 0:
|
|
368
|
+
return None
|
|
369
|
+
try:
|
|
370
|
+
value = json.loads(result.stdout)
|
|
371
|
+
except (TypeError, json.JSONDecodeError):
|
|
372
|
+
return None
|
|
373
|
+
return value if isinstance(value, dict) else None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _actions_run_and_check_id(details_url, owner, name, host):
|
|
377
|
+
"""Return trusted Actions run/check IDs only for this repository's canonical job URL."""
|
|
378
|
+
if not isinstance(details_url, str):
|
|
379
|
+
return None
|
|
380
|
+
parsed = urlsplit(details_url)
|
|
381
|
+
expected_host = (host or "github.com").lower()
|
|
382
|
+
if parsed.scheme != "https" or parsed.netloc.lower() != expected_host:
|
|
383
|
+
return None
|
|
384
|
+
match = re.fullmatch(
|
|
385
|
+
rf"/{re.escape(owner)}/{re.escape(name)}/actions/runs/(\d+)/job/(\d+)/?",
|
|
386
|
+
parsed.path,
|
|
387
|
+
)
|
|
388
|
+
if not match:
|
|
389
|
+
return None
|
|
390
|
+
return int(match.group(1)), int(match.group(2))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _same_head(value, head):
|
|
394
|
+
return isinstance(value, str) and isinstance(head, str) and value.lower() == head.lower()
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _corroborated_terminal_check_status(check, owner, name, head, host):
|
|
398
|
+
"""Normalize only GitHub's impossible running-but-concluded CheckRun tuple.
|
|
399
|
+
|
|
400
|
+
A CheckRun remains pending unless its own REST record, linked check suite, and
|
|
401
|
+
parent Actions workflow all report the same terminal conclusion on this exact
|
|
402
|
+
PR head. This intentionally adds no best-effort success inference.
|
|
403
|
+
"""
|
|
404
|
+
if check.get("__typename") != "CheckRun":
|
|
405
|
+
return None
|
|
406
|
+
status = str(check.get("status") or "").upper()
|
|
407
|
+
conclusion = str(check.get("conclusion") or "").upper()
|
|
408
|
+
if status != "IN_PROGRESS" or not check.get("completedAt") or conclusion not in TERMINAL_CHECK_CONCLUSIONS:
|
|
409
|
+
return None
|
|
410
|
+
|
|
411
|
+
identifiers = _actions_run_and_check_id(check.get("detailsUrl"), owner, name, host)
|
|
412
|
+
if identifiers is None:
|
|
413
|
+
return None
|
|
414
|
+
workflow_run_id, job_id = identifiers
|
|
415
|
+
database_id = check.get("databaseId")
|
|
416
|
+
if database_id is not None and database_id != job_id:
|
|
417
|
+
return None
|
|
418
|
+
check_id = database_id or job_id
|
|
419
|
+
if not isinstance(check_id, int):
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
check_run = _api_json(f"repos/{owner}/{name}/check-runs/{check_id}", host)
|
|
423
|
+
if not check_run or not _same_head(check_run.get("head_sha"), head):
|
|
424
|
+
return None
|
|
425
|
+
if (str(check_run.get("conclusion") or "").upper() != conclusion
|
|
426
|
+
or not check_run.get("completed_at")):
|
|
427
|
+
return None
|
|
428
|
+
suite_id = (check_run.get("check_suite") or {}).get("id")
|
|
429
|
+
if not isinstance(suite_id, int):
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
job = _api_json(f"repos/{owner}/{name}/actions/jobs/{job_id}", host)
|
|
433
|
+
if (not job
|
|
434
|
+
or job.get("id") != job_id
|
|
435
|
+
or not _same_head(job.get("head_sha"), head)
|
|
436
|
+
or str(job.get("conclusion") or "").upper() != conclusion
|
|
437
|
+
or not job.get("completed_at")):
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
suite = _api_json(f"repos/{owner}/{name}/check-suites/{suite_id}", host)
|
|
441
|
+
workflow = _api_json(f"repos/{owner}/{name}/actions/runs/{workflow_run_id}", host)
|
|
442
|
+
for parent in (suite, workflow):
|
|
443
|
+
if (not parent
|
|
444
|
+
or not _same_head(parent.get("head_sha"), head)
|
|
445
|
+
or str(parent.get("status") or "").upper() != "COMPLETED"
|
|
446
|
+
or str(parent.get("conclusion") or "").upper() != conclusion):
|
|
447
|
+
return None
|
|
448
|
+
return "COMPLETED"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _valid_oid(value):
|
|
452
|
+
return isinstance(value, str) and bool(
|
|
453
|
+
re.fullmatch(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})", value))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def fetch_pr_merge_identity(pr, owner, name, host=None):
|
|
457
|
+
"""Read mergeability and the identities it was computed from in one GraphQL observation."""
|
|
458
|
+
query = """
|
|
459
|
+
query($owner:String!,$repo:String!,$pr:Int!){
|
|
460
|
+
repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
|
|
461
|
+
mergeable mergeStateStatus headRefOid baseRefOid baseRefName viewerCanUpdateBranch
|
|
462
|
+
baseRef { target { oid } }
|
|
463
|
+
headRef { target { ... on Commit { parents(first:2) { nodes { oid } } } } }
|
|
464
|
+
potentialMergeCommit { oid parents(first:2) { nodes { oid } } }
|
|
465
|
+
} }
|
|
466
|
+
}"""
|
|
467
|
+
r = _run(["gh", "api", "graphql", *_host_args(host), "-f", f"owner={owner}",
|
|
468
|
+
"-f", f"repo={name}", "-F", f"pr={pr}", "-f", f"query={query}"])
|
|
469
|
+
if r.returncode != 0:
|
|
470
|
+
return None
|
|
471
|
+
try:
|
|
472
|
+
value = json.loads(r.stdout)["data"]["repository"]["pullRequest"]
|
|
473
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
474
|
+
return None
|
|
475
|
+
return value if isinstance(value, dict) else None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def fetch_base_ref(owner, name, ref, merge_identity, host=None):
|
|
479
|
+
"""Bind GitHub's mergeability observation to an independently probed current base ref.
|
|
480
|
+
|
|
481
|
+
`baseRefOid` is retained only as historical PR metadata. Current identity comes from both the
|
|
482
|
+
PR's `baseRef.target.oid` and an exact Git-ref read; a usable generated test merge must then
|
|
483
|
+
name that base and the observed PR head as its two parents. Some valid private-repository OAuth
|
|
484
|
+
sessions return 404 from the REST Git-ref endpoint, so that response falls back to the Git
|
|
485
|
+
transport while preserving exact-ref matching and non-interactive failure behavior.
|
|
486
|
+
"""
|
|
487
|
+
historical_oid = (merge_identity or {}).get("baseRefOid")
|
|
488
|
+
head_oid = (merge_identity or {}).get("headRefOid")
|
|
489
|
+
mergeable = (merge_identity or {}).get("mergeable")
|
|
490
|
+
merge_state_status = (merge_identity or {}).get("mergeStateStatus")
|
|
491
|
+
graphql_oid = (((merge_identity or {}).get("baseRef") or {}).get("target") or {}).get("oid")
|
|
492
|
+
potential_merge = (merge_identity or {}).get("potentialMergeCommit")
|
|
493
|
+
merge_commit_oid = potential_merge.get("oid") if isinstance(potential_merge, dict) else None
|
|
494
|
+
merge_parent_oids = (((potential_merge or {}).get("parents") or {}).get("nodes")
|
|
495
|
+
if isinstance(potential_merge, dict) else None)
|
|
496
|
+
if isinstance(merge_parent_oids, list):
|
|
497
|
+
merge_parent_oids = [node.get("oid") if isinstance(node, dict) else None
|
|
498
|
+
for node in merge_parent_oids]
|
|
499
|
+
else:
|
|
500
|
+
merge_parent_oids = []
|
|
501
|
+
base = {
|
|
502
|
+
"host": host or "github.com",
|
|
503
|
+
"repository": f"{owner}/{name}",
|
|
504
|
+
"ref": ref,
|
|
505
|
+
"oid": None,
|
|
506
|
+
"graphql_oid": graphql_oid,
|
|
507
|
+
"historical_oid": historical_oid,
|
|
508
|
+
"merge_commit_oid": merge_commit_oid,
|
|
509
|
+
"merge_parent_oids": merge_parent_oids,
|
|
510
|
+
"identity": BASE_REF_PROBE_ERROR,
|
|
511
|
+
}
|
|
512
|
+
if not ref:
|
|
513
|
+
return base
|
|
514
|
+
encoded_ref = quote(ref, safe="/")
|
|
515
|
+
r = _run([
|
|
516
|
+
"gh", "api", *_host_args(host),
|
|
517
|
+
f"repos/{owner}/{name}/git/ref/heads/{encoded_ref}",
|
|
518
|
+
"--jq", ".object.sha",
|
|
519
|
+
])
|
|
520
|
+
current_oid = (r.stdout or "").strip()
|
|
521
|
+
if r.returncode != 0 and re.search(r"\bHTTP\s+404\b", r.stderr or "", re.IGNORECASE):
|
|
522
|
+
exact_ref = f"refs/heads/{ref}"
|
|
523
|
+
remote_url = (f"https://{host or 'github.com'}/"
|
|
524
|
+
f"{quote(owner, safe='')}/{quote(name, safe='')}.git")
|
|
525
|
+
try:
|
|
526
|
+
# Reuse the gh session (GH_TOKEN or keyring) as git's credential source so a
|
|
527
|
+
# headless host with no configured Git credential helper is not anonymous here.
|
|
528
|
+
git_result = _run_git([
|
|
529
|
+
"git", "-c", "core.askPass=",
|
|
530
|
+
"-c", "credential.helper=",
|
|
531
|
+
"-c", "credential.helper=!gh auth git-credential",
|
|
532
|
+
"ls-remote", "--exit-code", "--refs",
|
|
533
|
+
remote_url, exact_ref,
|
|
534
|
+
])
|
|
535
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
536
|
+
git_result = None
|
|
537
|
+
if git_result is not None and git_result.returncode == 0:
|
|
538
|
+
matches = []
|
|
539
|
+
for line in (git_result.stdout or "").splitlines():
|
|
540
|
+
fields = line.split()
|
|
541
|
+
if len(fields) == 2 and fields[1] == exact_ref and _valid_oid(fields[0]):
|
|
542
|
+
matches.append(fields[0])
|
|
543
|
+
if len(matches) == 1:
|
|
544
|
+
current_oid = matches[0]
|
|
545
|
+
r = git_result
|
|
546
|
+
if r.returncode != 0 or not _valid_oid(current_oid):
|
|
547
|
+
return base
|
|
548
|
+
base["oid"] = current_oid
|
|
549
|
+
if not (_valid_oid(graphql_oid) and _valid_oid(head_oid)):
|
|
550
|
+
return base
|
|
551
|
+
if current_oid.lower() != graphql_oid.lower():
|
|
552
|
+
base["identity"] = BASE_REF_RACE
|
|
553
|
+
return base
|
|
554
|
+
|
|
555
|
+
if mergeable == "MERGEABLE" and merge_state_status not in (None, "UNKNOWN", "DIRTY"):
|
|
556
|
+
if potential_merge is None:
|
|
557
|
+
base["identity"] = BASE_REF_MERGEABILITY_PENDING
|
|
558
|
+
return base
|
|
559
|
+
if (not _valid_oid(merge_commit_oid) or len(merge_parent_oids) != 2
|
|
560
|
+
or not all(_valid_oid(oid) for oid in merge_parent_oids)):
|
|
561
|
+
return base
|
|
562
|
+
if (merge_parent_oids[0].lower() != current_oid.lower()
|
|
563
|
+
or merge_parent_oids[1].lower() != head_oid.lower()):
|
|
564
|
+
base["identity"] = BASE_REF_RACE
|
|
565
|
+
# Only the base parent lags while head and live base agree: a stale test merge,
|
|
566
|
+
# eligible for the bounded degrade in diff().
|
|
567
|
+
base["stale_computation_candidate"] = (
|
|
568
|
+
merge_parent_oids[1].lower() == head_oid.lower())
|
|
569
|
+
return base
|
|
570
|
+
elif not (mergeable == "CONFLICTING" and merge_state_status == "DIRTY"):
|
|
571
|
+
base["identity"] = BASE_REF_MERGEABILITY_PENDING
|
|
572
|
+
return base
|
|
573
|
+
|
|
574
|
+
base["identity"] = BASE_REF_CURRENT
|
|
575
|
+
return base
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _base_ref_blocker(base):
|
|
579
|
+
identity = (base or {}).get("identity")
|
|
580
|
+
if identity is None:
|
|
581
|
+
# Compatibility for pre-change --fetch-file fixtures and persisted snapshots.
|
|
582
|
+
identity = (base or {}).get("freshness")
|
|
583
|
+
if identity in (BASE_REF_CURRENT, BASE_REF_STALE_COMPUTATION):
|
|
584
|
+
return None
|
|
585
|
+
if identity in (BASE_REF_RACE, BASE_REF_MERGEABILITY_PENDING):
|
|
586
|
+
return identity
|
|
587
|
+
if identity == BASE_REF_LEGACY_STALE:
|
|
588
|
+
return BASE_REF_LEGACY_STALE
|
|
589
|
+
return BASE_REF_PROBE_ERROR
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _stack_schema_unavailable(stderr):
|
|
593
|
+
"""Recognize only GraphQL schema errors for the private-preview PullRequest stack fields."""
|
|
594
|
+
unavailable_markers = ("doesn't exist on type", "does not exist on type",
|
|
595
|
+
"cannot query field", "unknown field")
|
|
596
|
+
for line in (stderr or "").splitlines():
|
|
597
|
+
lowered = line.lower()
|
|
598
|
+
if ("pullrequest" in lowered
|
|
599
|
+
and re.search(r"\bstack(?:entry)?\b", lowered)
|
|
600
|
+
and any(marker in lowered for marker in unavailable_markers)):
|
|
601
|
+
return True
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _fetch_default_branch(owner, name, host=None):
|
|
606
|
+
"""Read the default branch without relying on private-preview GraphQL fields."""
|
|
607
|
+
result = _run(["gh", "api", *_host_args(host), f"repos/{owner}/{name}",
|
|
608
|
+
"--jq", ".default_branch"])
|
|
609
|
+
if result.returncode != 0:
|
|
610
|
+
return None
|
|
611
|
+
branch = result.stdout.strip()
|
|
612
|
+
return branch if branch and branch != "null" else None
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _thread_identity(t):
|
|
616
|
+
"""The remote-truth identity of a thread's latest state."""
|
|
617
|
+
return (t.get("last_comment_id"), t.get("last_comment_at"))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _prior_thread_activity_identity(t):
|
|
621
|
+
"""The last external-review baseline already accepted for this parked thread."""
|
|
622
|
+
acted_identity = t.get("acted_identity")
|
|
623
|
+
if (t.get("disposition") in (DISPOSITION_DISPATCHED, DISPOSITION_NEEDS_HUMAN)
|
|
624
|
+
and isinstance(acted_identity, (list, tuple))
|
|
625
|
+
and len(acted_identity) == 2):
|
|
626
|
+
return tuple(acted_identity)
|
|
627
|
+
return _thread_identity(t)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _default_pr_chain():
|
|
631
|
+
"""Backward-compatible neutral shape for injected/older snapshots with no chain facts."""
|
|
632
|
+
return {
|
|
633
|
+
"manager_status": MANAGER_ABSENT,
|
|
634
|
+
"manager_source": None,
|
|
635
|
+
"relationship_status": RELATIONSHIP_INDEPENDENT,
|
|
636
|
+
"trunk": None,
|
|
637
|
+
"default_branch": None,
|
|
638
|
+
"current_branch": None,
|
|
639
|
+
"target_position": None,
|
|
640
|
+
"target_needs_rebase": None,
|
|
641
|
+
"upstack_needs_rebase": [],
|
|
642
|
+
"entries": [],
|
|
643
|
+
"parent_prs": [],
|
|
644
|
+
"dependent_prs": [],
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _pr_summary(pr):
|
|
649
|
+
if not pr:
|
|
650
|
+
return None
|
|
651
|
+
return {k: pr.get(k) for k in ("number", "url", "state", "isDraft", "baseRefName", "headRefName")
|
|
652
|
+
if pr.get(k) is not None}
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _pr_url_identity(url):
|
|
656
|
+
"""Normalize a GitHub PR URL to a repository-scoped identity tuple."""
|
|
657
|
+
if not isinstance(url, str):
|
|
658
|
+
return None
|
|
659
|
+
try:
|
|
660
|
+
parsed = urlsplit(url.strip())
|
|
661
|
+
parts = [part for part in parsed.path.split("/") if part]
|
|
662
|
+
if (parsed.scheme.lower() not in ("http", "https") or not parsed.netloc
|
|
663
|
+
or len(parts) < 4 or parts[-2].lower() != "pull"):
|
|
664
|
+
return None
|
|
665
|
+
number = int(parts[-1])
|
|
666
|
+
except (TypeError, ValueError):
|
|
667
|
+
return None
|
|
668
|
+
return (parsed.netloc.lower(), parts[-4].lower(), parts[-3].lower(), number)
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _chain_from_entries(entries, pr, source, trunk=None, current_branch=None,
|
|
672
|
+
manager_id=None, manager_number=None, target_url=None):
|
|
673
|
+
"""Normalize an ordered manager-owned chain and locate the requested PR within it."""
|
|
674
|
+
target_identity = _pr_url_identity(target_url) if target_url is not None else None
|
|
675
|
+
target_index = next((i for i, e in enumerate(entries)
|
|
676
|
+
if (e.get("number") == pr or str(e.get("number")) == str(pr))
|
|
677
|
+
and (target_url is None
|
|
678
|
+
or (target_identity is not None
|
|
679
|
+
and _pr_url_identity(e.get("url")) == target_identity))), None)
|
|
680
|
+
if target_index is None:
|
|
681
|
+
return None
|
|
682
|
+
target = entries[target_index]
|
|
683
|
+
upstack_stale = [
|
|
684
|
+
{k: e.get(k) for k in ("number", "position", "name", "url") if e.get(k) is not None}
|
|
685
|
+
for e in entries[target_index + 1:] if e.get("needs_rebase") is True
|
|
686
|
+
]
|
|
687
|
+
chain = _default_pr_chain()
|
|
688
|
+
chain.update({
|
|
689
|
+
"manager_status": MANAGER_CONFIRMED,
|
|
690
|
+
"manager_source": source,
|
|
691
|
+
"manager_id": manager_id,
|
|
692
|
+
"manager_number": manager_number,
|
|
693
|
+
"relationship_status": RELATIONSHIP_DEPENDENT if len(entries) > 1 else RELATIONSHIP_INDEPENDENT,
|
|
694
|
+
"trunk": trunk,
|
|
695
|
+
"current_branch": current_branch,
|
|
696
|
+
"target_position": target.get("position") or target_index + 1,
|
|
697
|
+
"target_needs_rebase": target.get("needs_rebase"),
|
|
698
|
+
"upstack_needs_rebase": upstack_stale,
|
|
699
|
+
"entries": entries,
|
|
700
|
+
"parent_prs": [_pr_summary(entries[target_index - 1])] if target_index > 0 else [],
|
|
701
|
+
"dependent_prs": [_pr_summary(e) for e in entries[target_index + 1:]],
|
|
702
|
+
})
|
|
703
|
+
return chain
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _chain_from_stack_view(raw, pr, target_url):
|
|
707
|
+
entries = []
|
|
708
|
+
for index, branch in enumerate((raw or {}).get("branches") or []):
|
|
709
|
+
remote_pr = branch.get("pr") or {}
|
|
710
|
+
entries.append({
|
|
711
|
+
"position": index + 1,
|
|
712
|
+
"name": branch.get("name"),
|
|
713
|
+
"head": branch.get("head"),
|
|
714
|
+
"base": branch.get("base"),
|
|
715
|
+
"is_current": bool(branch.get("isCurrent")),
|
|
716
|
+
"is_merged": bool(branch.get("isMerged")),
|
|
717
|
+
"is_queued": bool(branch.get("isQueued")),
|
|
718
|
+
"needs_rebase": branch.get("needsRebase"),
|
|
719
|
+
"number": remote_pr.get("number"),
|
|
720
|
+
"url": remote_pr.get("url"),
|
|
721
|
+
"state": remote_pr.get("state"),
|
|
722
|
+
"isDraft": remote_pr.get("isDraft"),
|
|
723
|
+
})
|
|
724
|
+
return _chain_from_entries(entries, pr, "gh-stack", (raw or {}).get("trunk"),
|
|
725
|
+
(raw or {}).get("currentBranch"), target_url=target_url)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _fetch_graphql_stack(pr, owner, name, host=None):
|
|
729
|
+
"""Read-only remote membership fallback. A successful null stack is distinct from failure."""
|
|
730
|
+
query = """
|
|
731
|
+
query($owner:String!,$repo:String!,$pr:Int!){
|
|
732
|
+
repository(owner:$owner,name:$repo){
|
|
733
|
+
defaultBranchRef{ name }
|
|
734
|
+
pullRequest(number:$pr){
|
|
735
|
+
stackEntry{ position }
|
|
736
|
+
stack{ id number size baseRefName
|
|
737
|
+
entries(first:100){ nodes{ position pullRequest{
|
|
738
|
+
number url state isDraft baseRefName headRefName headRefOid
|
|
739
|
+
} } }
|
|
740
|
+
}
|
|
741
|
+
} }
|
|
742
|
+
}"""
|
|
743
|
+
args = ["gh", "api", "graphql", *_host_args(host), "-f", f"owner={owner}", "-f", f"repo={name}",
|
|
744
|
+
"-F", f"pr={pr}", "-f", f"query={query}"]
|
|
745
|
+
r = _run(args)
|
|
746
|
+
if r.returncode != 0:
|
|
747
|
+
if _stack_schema_unavailable(r.stderr):
|
|
748
|
+
default_branch = _fetch_default_branch(owner, name, host)
|
|
749
|
+
if default_branch:
|
|
750
|
+
return MANAGER_ABSENT, None, default_branch
|
|
751
|
+
return MANAGER_PROBE_ERROR, None, None
|
|
752
|
+
try:
|
|
753
|
+
repository = json.loads(r.stdout)["data"]["repository"]
|
|
754
|
+
default_branch = (repository.get("defaultBranchRef") or {}).get("name")
|
|
755
|
+
node = repository["pullRequest"]
|
|
756
|
+
stack = node.get("stack")
|
|
757
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
758
|
+
return MANAGER_PROBE_ERROR, None, None
|
|
759
|
+
if stack is None:
|
|
760
|
+
return MANAGER_ABSENT, None, default_branch
|
|
761
|
+
entries = []
|
|
762
|
+
for raw in (stack.get("entries") or {}).get("nodes") or []:
|
|
763
|
+
remote_pr = raw.get("pullRequest") or {}
|
|
764
|
+
entries.append({
|
|
765
|
+
"position": raw.get("position"),
|
|
766
|
+
"name": remote_pr.get("headRefName"),
|
|
767
|
+
"head": remote_pr.get("headRefOid"),
|
|
768
|
+
"base_ref_name": remote_pr.get("baseRefName"),
|
|
769
|
+
"needs_rebase": None,
|
|
770
|
+
**(_pr_summary(remote_pr) or {}),
|
|
771
|
+
})
|
|
772
|
+
chain = _chain_from_entries(entries, pr, "graphql", stack.get("baseRefName"),
|
|
773
|
+
manager_id=stack.get("id"), manager_number=stack.get("number"))
|
|
774
|
+
return ((MANAGER_CONFIRMED, chain, default_branch) if chain
|
|
775
|
+
else (MANAGER_PROBE_ERROR, None, default_branch))
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _manual_relationships(pr, repo, base_ref_name, head_ref_name, owner, name,
|
|
779
|
+
host=None, default_branch=None):
|
|
780
|
+
"""Find ordinary parent/children with narrow read-only branch filters."""
|
|
781
|
+
repo_ref = repo or f"{owner}/{name}"
|
|
782
|
+
if host and repo_ref.count("/") == 1:
|
|
783
|
+
repo_ref = f"{host}/{repo_ref}"
|
|
784
|
+
fields = "number,url,state,isDraft,baseRefName,headRefName"
|
|
785
|
+
parent_result = None
|
|
786
|
+
if base_ref_name and base_ref_name != default_branch:
|
|
787
|
+
parent_result = _run(["gh", "pr", "list", "--repo", repo_ref, "--state", "all",
|
|
788
|
+
"--head", base_ref_name, "--limit", "20", "--json", fields])
|
|
789
|
+
dependent_result = _run(["gh", "pr", "list", "--repo", repo_ref, "--state", "open",
|
|
790
|
+
"--base", head_ref_name or "", "--limit", "100", "--json", fields])
|
|
791
|
+
if ((parent_result is not None and parent_result.returncode != 0)
|
|
792
|
+
or dependent_result.returncode != 0):
|
|
793
|
+
return RELATIONSHIP_PROBE_ERROR, [], []
|
|
794
|
+
try:
|
|
795
|
+
parent_candidates = json.loads(parent_result.stdout) or [] if parent_result else []
|
|
796
|
+
dependent_candidates = json.loads(dependent_result.stdout) or []
|
|
797
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
798
|
+
return RELATIONSHIP_PROBE_ERROR, [], []
|
|
799
|
+
parents = [_pr_summary(p) for p in parent_candidates
|
|
800
|
+
if p.get("number") != pr and p.get("headRefName") == base_ref_name]
|
|
801
|
+
dependents = [_pr_summary(p) for p in dependent_candidates
|
|
802
|
+
if p.get("number") != pr and p.get("state") == "OPEN"
|
|
803
|
+
and p.get("baseRefName") == head_ref_name]
|
|
804
|
+
status = RELATIONSHIP_DEPENDENT if parents or dependents else RELATIONSHIP_INDEPENDENT
|
|
805
|
+
return status, parents, dependents
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def fetch_pr_chain(pr, repo, url, base_ref_name, head_ref_name, owner, name, host=None):
|
|
809
|
+
"""Classify manager membership and ordinary dependency relationships without mutation.
|
|
810
|
+
|
|
811
|
+
The local manager is the fast/rich path, but its output is accepted only when it contains the
|
|
812
|
+
requested PR. `gh stack view` has no target argument and may describe a different current stack.
|
|
813
|
+
"""
|
|
814
|
+
local = _run(["gh", "stack", "view", "--json"])
|
|
815
|
+
if local.returncode == 0:
|
|
816
|
+
try:
|
|
817
|
+
chain = _chain_from_stack_view(json.loads(local.stdout), pr, url)
|
|
818
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
819
|
+
chain = None
|
|
820
|
+
if chain:
|
|
821
|
+
return chain
|
|
822
|
+
|
|
823
|
+
manager_status, chain, default_branch = _fetch_graphql_stack(pr, owner, name, host)
|
|
824
|
+
if manager_status == MANAGER_CONFIRMED:
|
|
825
|
+
chain["default_branch"] = default_branch
|
|
826
|
+
return chain
|
|
827
|
+
|
|
828
|
+
relationship, parents, dependents = _manual_relationships(
|
|
829
|
+
pr, repo, base_ref_name, head_ref_name, owner, name, host, default_branch)
|
|
830
|
+
result = _default_pr_chain()
|
|
831
|
+
result.update({
|
|
832
|
+
"manager_status": manager_status,
|
|
833
|
+
"relationship_status": relationship,
|
|
834
|
+
"default_branch": default_branch,
|
|
835
|
+
"parent_prs": parents,
|
|
836
|
+
"dependent_prs": dependents,
|
|
837
|
+
})
|
|
838
|
+
return result
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def fetch(pr, repo, light=False):
|
|
842
|
+
"""Fetch both event streams via gh into one combined snapshot dict. `light` skips the probes a
|
|
843
|
+
downstack signature never reads (merge identity/base ref, eyes reactions, awaiting-approval)."""
|
|
844
|
+
repo_args = ["--repo", repo] if repo else []
|
|
845
|
+
view = _run_checked(["gh", "pr", "view", str(pr), *repo_args, "--json",
|
|
846
|
+
"state,mergeable,mergeStateStatus,reviewDecision,headRefOid,baseRefName,headRefName,url,number,isDraft,"
|
|
847
|
+
"statusCheckRollup,author,comments,reviews"],
|
|
848
|
+
"gh pr view")
|
|
849
|
+
v = json.loads(view.stdout)
|
|
850
|
+
|
|
851
|
+
owner, name, host = _resolve_repo_ref(repo, v.get("url"))
|
|
852
|
+
head = v.get("headRefOid")
|
|
853
|
+
checks = []
|
|
854
|
+
for c in v.get("statusCheckRollup") or []:
|
|
855
|
+
# CheckRun entries carry startedAt/completedAt; StatusContext (legacy commit statuses)
|
|
856
|
+
# carry createdAt. Preserve the provider's real creation identity for rerun reconciliation.
|
|
857
|
+
if c.get("__typename") == "StatusContext":
|
|
858
|
+
check_name = c.get("context") or "status"
|
|
859
|
+
state = (c.get("state") or "").upper()
|
|
860
|
+
checks.append({
|
|
861
|
+
"key": check_name, "name": check_name,
|
|
862
|
+
"status": "COMPLETED" if state in ("SUCCESS", "FAILURE", "ERROR") else "IN_PROGRESS",
|
|
863
|
+
"conclusion": {"ERROR": "FAILURE"}.get(state, state) or None,
|
|
864
|
+
"details_url": c.get("targetUrl"),
|
|
865
|
+
"created_at": c.get("createdAt"),
|
|
866
|
+
"started_at": None,
|
|
867
|
+
"completed_at": None,
|
|
868
|
+
})
|
|
869
|
+
else:
|
|
870
|
+
wf = c.get("workflowName")
|
|
871
|
+
check_name = c.get("name") or "check"
|
|
872
|
+
status = (c.get("status") or "").upper() or "IN_PROGRESS"
|
|
873
|
+
corroborated_status = None if light else _corroborated_terminal_check_status(
|
|
874
|
+
c, owner, name, head, host)
|
|
875
|
+
if corroborated_status:
|
|
876
|
+
status = corroborated_status
|
|
877
|
+
checks.append({
|
|
878
|
+
"key": f"{wf}/{check_name}" if wf else check_name, "name": check_name,
|
|
879
|
+
"status": status,
|
|
880
|
+
"conclusion": (c.get("conclusion") or None) and c["conclusion"].upper(),
|
|
881
|
+
"details_url": c.get("detailsUrl"),
|
|
882
|
+
"created_at": None,
|
|
883
|
+
"started_at": c.get("startedAt"),
|
|
884
|
+
"completed_at": c.get("completedAt"),
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
merge_identity = None if light else fetch_pr_merge_identity(v.get("number") or pr, owner, name, host)
|
|
888
|
+
merge_observation = merge_identity or v
|
|
889
|
+
mergeable = merge_observation.get("mergeable")
|
|
890
|
+
merge_state_status = merge_observation.get("mergeStateStatus")
|
|
891
|
+
if light:
|
|
892
|
+
base = {"host": host or "github.com", "repository": f"{owner}/{name}",
|
|
893
|
+
"ref": v.get("baseRefName"), "oid": None, "identity": BASE_REF_PROBE_ERROR}
|
|
894
|
+
else:
|
|
895
|
+
base = fetch_base_ref(owner, name, v.get("baseRefName"), merge_observation, host)
|
|
896
|
+
if merge_identity is not None:
|
|
897
|
+
identity_head = merge_identity.get("headRefOid")
|
|
898
|
+
identity_base_ref = merge_identity.get("baseRefName")
|
|
899
|
+
if (not isinstance(identity_base_ref, str)
|
|
900
|
+
or not (_valid_oid(identity_head) and _valid_oid(head))):
|
|
901
|
+
base["identity"] = BASE_REF_PROBE_ERROR
|
|
902
|
+
elif (identity_head.lower() != head.lower()
|
|
903
|
+
or identity_base_ref != v.get("baseRefName")):
|
|
904
|
+
# The checks and review payload came from `v`; do not combine them with mergeability
|
|
905
|
+
# observed for a different head or base branch.
|
|
906
|
+
base["identity"] = BASE_REF_RACE
|
|
907
|
+
pr_chain = fetch_pr_chain(v.get("number") or pr, repo, v.get("url"),
|
|
908
|
+
v.get("baseRefName"), v.get("headRefName"), owner, name, host)
|
|
909
|
+
currency_probe = {
|
|
910
|
+
"mergeable": mergeable,
|
|
911
|
+
"merge_state_status": merge_state_status,
|
|
912
|
+
"base": base,
|
|
913
|
+
"pr_chain": pr_chain,
|
|
914
|
+
}
|
|
915
|
+
host_branch_update_capability = "unknown"
|
|
916
|
+
if (merge_state_status == "BEHIND"
|
|
917
|
+
and _branch_currency_observation(currency_probe, head) is not None):
|
|
918
|
+
capability = merge_observation.get("viewerCanUpdateBranch")
|
|
919
|
+
if isinstance(capability, bool):
|
|
920
|
+
host_branch_update_capability = capability
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
"pr_state": v.get("state"),
|
|
924
|
+
"pr_is_draft": v.get("isDraft"),
|
|
925
|
+
"mergeable": mergeable,
|
|
926
|
+
"merge_state_status": merge_state_status,
|
|
927
|
+
"review_decision": v.get("reviewDecision") or None,
|
|
928
|
+
"head_sha": head,
|
|
929
|
+
"base": base,
|
|
930
|
+
"host_branch_update_capability": host_branch_update_capability,
|
|
931
|
+
"url": v.get("url"),
|
|
932
|
+
"checks": checks,
|
|
933
|
+
"threads": fetch_threads(pr, owner, name, host),
|
|
934
|
+
# Non-thread feedback: top-level PR comments + review submission bodies. ce-resolve-pr-feedback
|
|
935
|
+
# handles these too, so a Changes-Requested review body or an actionable top-level comment with
|
|
936
|
+
# NO inline thread must not be invisible to the loop. Content-actionability is entirely the
|
|
937
|
+
# resolver's judgment; this deterministic layer keeps every non-empty non-author body.
|
|
938
|
+
"feedback": _extract_feedback(v),
|
|
939
|
+
"awaiting_approval": None if light else fetch_awaiting_approval(owner, name, head, host),
|
|
940
|
+
"head_parents": _head_parents(merge_identity),
|
|
941
|
+
"pr_chain": pr_chain,
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _body_hash(body):
|
|
946
|
+
"""Edit identity for a top-level comment / review body: `gh pr view --json` exposes no
|
|
947
|
+
updatedAt, so hash the body. Feeds the external_review_moved activity signal (an edit wakes
|
|
948
|
+
the watch and extends the settle clock) and `needs-human` reactivation (a human may answer a
|
|
949
|
+
parked question by editing the same comment), but deliberately NOT `dispatched` reactivation:
|
|
950
|
+
status bots rewrite their bodies on every push, so edit-keyed reactivation of handled items
|
|
951
|
+
re-actionized bot comments forever (#1309)."""
|
|
952
|
+
return hashlib.sha1((body or "").encode("utf-8")).hexdigest()[:16]
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _extract_feedback(v):
|
|
956
|
+
"""Every non-empty top-level PR comment and review body, whoever wrote it.
|
|
957
|
+
`gh pr view --json comments,reviews` returns flat arrays (not GraphQL {nodes}). Content,
|
|
958
|
+
identity, and surface are evidence for the resolver to judge, not deterministic exclusions --
|
|
959
|
+
and that includes the PR author, whose own top-level "please rename X" is the ordinary way a
|
|
960
|
+
human asks an agent-opened PR for a change. Excluding the author here made that request
|
|
961
|
+
invisible to the tick, so no resolver pass ever ran for it. Loop prevention is the dispatched
|
|
962
|
+
mark, which silences a candidate permanently once it has been classified, not identity."""
|
|
963
|
+
out = []
|
|
964
|
+
for c in v.get("comments") or []:
|
|
965
|
+
if (c.get("body") or "").strip():
|
|
966
|
+
out.append({"id": c.get("id"), "kind": "comment",
|
|
967
|
+
"author": (c.get("author") or {}).get("login"),
|
|
968
|
+
"edit_id": _body_hash(c.get("body"))})
|
|
969
|
+
for r in v.get("reviews") or []:
|
|
970
|
+
if (r.get("body") or "").strip():
|
|
971
|
+
out.append({"id": r.get("id"), "kind": "review",
|
|
972
|
+
"author": (r.get("author") or {}).get("login"),
|
|
973
|
+
"state": r.get("state"), "edit_id": _body_hash(r.get("body"))})
|
|
974
|
+
return out
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _head_parents(merge_identity):
|
|
978
|
+
"""Parent OIDs of the PR head from the merge-identity GraphQL read, or None when absent. Feeds
|
|
979
|
+
the unrequested base-merge detector: a two-parent head whose second parent is the base tip,
|
|
980
|
+
produced without a claimed branch_currency item, is a base-into-head merge nobody asked for."""
|
|
981
|
+
nodes = ((((merge_identity or {}).get("headRef") or {}).get("target") or {})
|
|
982
|
+
.get("parents") or {}).get("nodes")
|
|
983
|
+
if not isinstance(nodes, list):
|
|
984
|
+
return None
|
|
985
|
+
oids = [node.get("oid") if isinstance(node, dict) else None for node in nodes]
|
|
986
|
+
return [str(o).lower() for o in oids if _valid_oid(o)]
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def fetch_awaiting_approval(owner, name, head, host=None):
|
|
990
|
+
"""Count Actions workflow runs on this head that are awaiting maintainer approval —
|
|
991
|
+
the fork-PR security gate. Such a run has created NO check-run yet, so it is invisible
|
|
992
|
+
to statusCheckRollup; without this, a fork PR blocked on approval reads as 'all checks ok'.
|
|
993
|
+
Best-effort: an API/permission failure returns None so the tick can preserve the
|
|
994
|
+
last proven gate state rather than treating an unknown result as a proven clear."""
|
|
995
|
+
if not head:
|
|
996
|
+
return 0
|
|
997
|
+
r = _run(["gh", "api", *_host_args(host),
|
|
998
|
+
f"repos/{owner}/{name}/actions/runs?head_sha={head}&per_page=50",
|
|
999
|
+
"--jq", '[.workflow_runs[] | select(.status==\"action_required\" or .status==\"waiting\" '
|
|
1000
|
+
'or .conclusion==\"action_required\")] | length'])
|
|
1001
|
+
if r.returncode != 0:
|
|
1002
|
+
return None
|
|
1003
|
+
try:
|
|
1004
|
+
return int((r.stdout or "").strip() or 0)
|
|
1005
|
+
except ValueError:
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def fetch_threads(pr, owner, name, host=None):
|
|
1010
|
+
"""Unresolved review threads with their last-comment identity."""
|
|
1011
|
+
query = """
|
|
1012
|
+
query($owner:String!,$repo:String!,$pr:Int!,$cursor:String){
|
|
1013
|
+
repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
|
|
1014
|
+
reviewThreads(first:100,after:$cursor){
|
|
1015
|
+
nodes{ id isResolved path line
|
|
1016
|
+
root: comments(first:1){ nodes{ url } }
|
|
1017
|
+
comments(last:100){ nodes{ id createdAt lastEditedAt } } }
|
|
1018
|
+
pageInfo{ hasNextPage endCursor } } } } }"""
|
|
1019
|
+
out = []
|
|
1020
|
+
cursor = None
|
|
1021
|
+
while True:
|
|
1022
|
+
args = ["gh", "api", "graphql", *_host_args(host), "-f", f"owner={owner}", "-f", f"repo={name}",
|
|
1023
|
+
"-F", f"pr={pr}", "-f", f"query={query}"]
|
|
1024
|
+
if cursor:
|
|
1025
|
+
args += ["-f", f"cursor={cursor}"]
|
|
1026
|
+
r = _run_checked(args, "gh api graphql")
|
|
1027
|
+
data = json.loads(r.stdout)["data"]["repository"]["pullRequest"]["reviewThreads"]
|
|
1028
|
+
for n in data["nodes"]:
|
|
1029
|
+
if n.get("isResolved"):
|
|
1030
|
+
continue
|
|
1031
|
+
cs = n.get("comments", {}).get("nodes") or []
|
|
1032
|
+
last = cs[-1] if cs else {}
|
|
1033
|
+
# `last_comment_at` is the reactivation signal — the MAX edit/create time across every
|
|
1034
|
+
# comment in the thread, not just the last one, so a reviewer editing an *earlier* comment
|
|
1035
|
+
# (their original request) after the agent's reply still moves the identity and re-opens it.
|
|
1036
|
+
# Bounded to the last 100 comments (see the query): review threads are ~never that long; an
|
|
1037
|
+
# edit to a comment outside that window would be missed (acceptable vs paginating per thread).
|
|
1038
|
+
edit_at = max((c.get("lastEditedAt") or c.get("createdAt") or "" for c in cs), default="")
|
|
1039
|
+
root = (n.get("root") or {}).get("nodes") or []
|
|
1040
|
+
out.append({
|
|
1041
|
+
"thread_id": n["id"],
|
|
1042
|
+
"last_comment_id": last.get("id"),
|
|
1043
|
+
"last_comment_at": edit_at or last.get("lastEditedAt") or last.get("createdAt"),
|
|
1044
|
+
"url": root[0].get("url") if root else None,
|
|
1045
|
+
"path": n.get("path"),
|
|
1046
|
+
"line": n.get("line"),
|
|
1047
|
+
})
|
|
1048
|
+
if not data["pageInfo"]["hasNextPage"]:
|
|
1049
|
+
break
|
|
1050
|
+
cursor = data["pageInfo"]["endCursor"]
|
|
1051
|
+
return out
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _empty_state(pr, repo, url, now):
|
|
1055
|
+
owner, name = _split_repo(repo)
|
|
1056
|
+
created_at = _iso(now)
|
|
1057
|
+
return {
|
|
1058
|
+
"pr": {"owner": owner, "repo": name, "number": pr, "url": url},
|
|
1059
|
+
"head_sha": None, "tick": 0, "state_created_at": created_at,
|
|
1060
|
+
"started_at": created_at, "invocation_id": None, "invocation_budget_seconds": None,
|
|
1061
|
+
"last_activity_at": created_at, "dead_time_seconds": 0.0,
|
|
1062
|
+
"invocation_backstop_seconds": DEFAULT_INVOCATION_BACKSTOP_SECONDS,
|
|
1063
|
+
"checks": {}, "threads": {}, "ci_dispatched": {},
|
|
1064
|
+
"human_decisions": [], "answered_human_decisions": [],
|
|
1065
|
+
"review_decision": None, "mergeable": None, "merge_state_status": None,
|
|
1066
|
+
"blocked_external_head_sha": None,
|
|
1067
|
+
"blocked_external_first_seen_at": None,
|
|
1068
|
+
"feedback_candidate_changed_at": None, "feedback_candidate_ids": [],
|
|
1069
|
+
"blocked_external_review_last_activity_at": None,
|
|
1070
|
+
"pr_chain": _default_pr_chain(),
|
|
1071
|
+
"base": None,
|
|
1072
|
+
"branch_currency_state": _empty_branch_currency_state(),
|
|
1073
|
+
"last_change_at": None, "last_action": None, "stop_reason": None,
|
|
1074
|
+
"watch_generation": None, "watch_pid": None, "watch_process_identity": None,
|
|
1075
|
+
"trajectory": _empty_trajectory(),
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _empty_branch_currency_state():
|
|
1080
|
+
return {"current_key": None, "items": {}, "semantic_parks": {}, "head_sha": None}
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _load_branch_currency_state(state):
|
|
1084
|
+
"""Conservatively migrate durable state written before branch currency existed."""
|
|
1085
|
+
currency = state.get("branch_currency_state")
|
|
1086
|
+
if not isinstance(currency, dict):
|
|
1087
|
+
currency = {}
|
|
1088
|
+
defaults = _empty_branch_currency_state()
|
|
1089
|
+
for key, value in defaults.items():
|
|
1090
|
+
currency.setdefault(key, value)
|
|
1091
|
+
if not isinstance(currency.get("items"), dict):
|
|
1092
|
+
currency["items"] = {}
|
|
1093
|
+
for item in currency["items"].values():
|
|
1094
|
+
if isinstance(item, dict) and item.get("requires_decision"):
|
|
1095
|
+
item.setdefault("mutation_requires_answer", True)
|
|
1096
|
+
if not isinstance(currency.get("semantic_parks"), dict):
|
|
1097
|
+
currency["semantic_parks"] = {}
|
|
1098
|
+
state["branch_currency_state"] = currency
|
|
1099
|
+
return currency
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _reactivate_currency_item(currency, item):
|
|
1103
|
+
"""Re-actionize one source without erasing attempt or mutation history."""
|
|
1104
|
+
item["disposition"] = DISPOSITION_OPEN
|
|
1105
|
+
item.pop("claimed_invocation_id", None)
|
|
1106
|
+
item.pop("confirmed_invocation_id", None)
|
|
1107
|
+
item.pop("reconciled_invocation_id", None)
|
|
1108
|
+
item.pop("inspected_semantic_conflict_fingerprint", None)
|
|
1109
|
+
item.pop("inspection_result", None)
|
|
1110
|
+
item.pop("requires_decision", None)
|
|
1111
|
+
item.pop("pending_decision", None)
|
|
1112
|
+
item.pop("answered_decision", None)
|
|
1113
|
+
item["parked_semantic_fingerprints"] = sorted(currency.get("semantic_parks") or {})
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _mergeability_certain(mergeable, merge_state_status, base):
|
|
1117
|
+
"""GitHub mergeability is usable only when its cached base is proven current."""
|
|
1118
|
+
if _base_ref_blocker(base) is not None:
|
|
1119
|
+
return False
|
|
1120
|
+
if mergeable not in ("MERGEABLE", "CONFLICTING"):
|
|
1121
|
+
return False
|
|
1122
|
+
if not merge_state_status or merge_state_status == "UNKNOWN":
|
|
1123
|
+
return False
|
|
1124
|
+
if merge_state_status == "DIRTY":
|
|
1125
|
+
return mergeable == "CONFLICTING"
|
|
1126
|
+
if merge_state_status == "BEHIND":
|
|
1127
|
+
return mergeable == "MERGEABLE"
|
|
1128
|
+
return True
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _normal_base_route(cur):
|
|
1132
|
+
"""Return the only route U1 may classify: an unmanaged PR rooted on its normal base.
|
|
1133
|
+
|
|
1134
|
+
Ordinary dependents are intentionally harmless. An open or unknown parent means this target is
|
|
1135
|
+
itself in a manual dependency chain and therefore outside this unit's authority.
|
|
1136
|
+
"""
|
|
1137
|
+
chain = cur.get("pr_chain") or _default_pr_chain()
|
|
1138
|
+
base = cur.get("base") or {}
|
|
1139
|
+
if chain.get("manager_status") != MANAGER_ABSENT:
|
|
1140
|
+
return None
|
|
1141
|
+
if chain.get("relationship_status") == RELATIONSHIP_PROBE_ERROR:
|
|
1142
|
+
return None
|
|
1143
|
+
if not chain.get("default_branch") or base.get("ref") != chain.get("default_branch"):
|
|
1144
|
+
return None
|
|
1145
|
+
parents = chain.get("parent_prs") or []
|
|
1146
|
+
if any(not isinstance(parent, dict) or parent.get("state") not in ("CLOSED", "MERGED")
|
|
1147
|
+
for parent in parents):
|
|
1148
|
+
return None
|
|
1149
|
+
return "normal-base"
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _branch_currency_observation(cur, head):
|
|
1153
|
+
status = cur.get("merge_state_status")
|
|
1154
|
+
certain = _mergeability_certain(cur.get("mergeable"), status, cur.get("base"))
|
|
1155
|
+
if not certain or status not in ("BEHIND", "DIRTY"):
|
|
1156
|
+
return None
|
|
1157
|
+
route = _normal_base_route(cur)
|
|
1158
|
+
base = cur.get("base") or {}
|
|
1159
|
+
if (route is None or not head
|
|
1160
|
+
or not all(base.get(key) for key in ("host", "repository", "ref", "oid"))):
|
|
1161
|
+
return None
|
|
1162
|
+
identity = {
|
|
1163
|
+
"host": base["host"],
|
|
1164
|
+
"base_repository": base["repository"],
|
|
1165
|
+
"base_ref": base["ref"],
|
|
1166
|
+
"base_oid": base["oid"],
|
|
1167
|
+
"head_sha": head,
|
|
1168
|
+
"status": status,
|
|
1169
|
+
"route": route,
|
|
1170
|
+
}
|
|
1171
|
+
encoded = json.dumps(identity, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
1172
|
+
return {"key": f"currency:{hashlib.sha256(encoded).hexdigest()}", **identity}
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _claimed_currency_items(currency):
|
|
1176
|
+
"""Return unresolved mutation claims with the current item first.
|
|
1177
|
+
|
|
1178
|
+
Base or expected head movement may be the result of the claimed mutation. It is evidence to
|
|
1179
|
+
reconcile, not permission to discard the claim and start another attempt.
|
|
1180
|
+
"""
|
|
1181
|
+
items = currency.get("items") or {}
|
|
1182
|
+
current_key = currency.get("current_key")
|
|
1183
|
+
claimed = []
|
|
1184
|
+
current = items.get(current_key)
|
|
1185
|
+
if isinstance(current, dict) and current.get("disposition") == CURRENCY_CLAIMED:
|
|
1186
|
+
claimed.append((current_key, current))
|
|
1187
|
+
claimed.extend(
|
|
1188
|
+
(key, item) for key, item in items.items()
|
|
1189
|
+
if key != current_key and isinstance(item, dict)
|
|
1190
|
+
and item.get("disposition") == CURRENCY_CLAIMED
|
|
1191
|
+
)
|
|
1192
|
+
return claimed
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
def _prepare_claimed_currency_item(state, item, cur=None, head=None):
|
|
1196
|
+
invocation_id = state.get("invocation_id")
|
|
1197
|
+
already_handled_here = (
|
|
1198
|
+
item.get("claimed_invocation_id") == invocation_id
|
|
1199
|
+
or item.get("reconciled_invocation_id") == invocation_id
|
|
1200
|
+
)
|
|
1201
|
+
base = (cur or {}).get("base") or {}
|
|
1202
|
+
async_evidence_moved = (
|
|
1203
|
+
item.get("recovery_state") in (
|
|
1204
|
+
CURRENCY_OUTCOME_MUTATION_OBSERVED, CURRENCY_OUTCOME_AMBIGUOUS)
|
|
1205
|
+
and ((head and head != item.get("head_sha"))
|
|
1206
|
+
or (base.get("oid") and base.get("oid") != item.get("base_oid")))
|
|
1207
|
+
)
|
|
1208
|
+
item["attention"] = (CURRENCY_ATTENTION_RECONCILE
|
|
1209
|
+
if async_evidence_moved or not already_handled_here else None)
|
|
1210
|
+
item["reconciliation_only"] = True
|
|
1211
|
+
return item
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _apply_stale_merge_computation(state, cur, head, now):
|
|
1215
|
+
"""Bounded degrade for a `race` whose only mismatch is GitHub's stale test merge. Tracks the
|
|
1216
|
+
first time the same (head, live base) pair was seen in that state; after
|
|
1217
|
+
STALE_MERGE_COMPUTATION_SECONDS the identity becomes `stale-computation` and the base carries
|
|
1218
|
+
`merge_computation_stale: true` so the report can disclose it. Any head/base movement resets."""
|
|
1219
|
+
base = cur.get("base") if isinstance(cur.get("base"), dict) else None
|
|
1220
|
+
if not base:
|
|
1221
|
+
state["stale_merge_computation"] = None
|
|
1222
|
+
return
|
|
1223
|
+
live = base.get("oid")
|
|
1224
|
+
candidate = (base.get("identity") == BASE_REF_RACE
|
|
1225
|
+
and base.get("stale_computation_candidate")
|
|
1226
|
+
and _valid_oid(live) and _valid_oid(head))
|
|
1227
|
+
if not candidate:
|
|
1228
|
+
state["stale_merge_computation"] = None
|
|
1229
|
+
return
|
|
1230
|
+
key = f"{str(head).lower()}:{str(live).lower()}"
|
|
1231
|
+
tracked = state.get("stale_merge_computation")
|
|
1232
|
+
if not isinstance(tracked, dict) or tracked.get("key") != key:
|
|
1233
|
+
tracked = {"key": key, "first_seen_at": _iso(now)}
|
|
1234
|
+
state["stale_merge_computation"] = tracked
|
|
1235
|
+
if _elapsed(tracked.get("first_seen_at"), now) >= STALE_MERGE_COMPUTATION_SECONDS:
|
|
1236
|
+
base["identity"] = BASE_REF_STALE_COMPUTATION
|
|
1237
|
+
base["merge_computation_stale"] = True
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _detect_unrequested_base_merge(state, cur, head, head_changed, now):
|
|
1241
|
+
"""Head-scoped fact: the current head is a two-parent merge of the base tip that no claimed
|
|
1242
|
+
branch_currency item produced. Ordinary base movement with CLEAN never yields an item, so a
|
|
1243
|
+
base-into-head merge on such a PR is a defect the loop must report, not maintenance. Persisted
|
|
1244
|
+
on the head that introduced it and cleared by the next head change; a legitimate DIRTY repair
|
|
1245
|
+
(mutation_consumed on a claimed item whose head is the merge's other parent) is excluded. A head
|
|
1246
|
+
whose parents could not be observed stays pending and is re-evaluated every snapshot. The head
|
|
1247
|
+
seen on the first-ever snapshot of a state dir is the watch's baseline, not a candidate: the
|
|
1248
|
+
loop has no evidence about who produced it (a user merging main by hand before invoking is
|
|
1249
|
+
ordinary), and state persists across re-invocations, so only heads that appear during the
|
|
1250
|
+
watch are classified."""
|
|
1251
|
+
# Must run before diff() overwrites state["base"] with the current observation.
|
|
1252
|
+
prior_base = state.get("base") if isinstance(state.get("base"), dict) else {}
|
|
1253
|
+
persisted = state.get("unrequested_base_merge")
|
|
1254
|
+
pending = state.get("unrequested_base_merge_pending")
|
|
1255
|
+
should_evaluate = head_changed or pending == head
|
|
1256
|
+
if not should_evaluate:
|
|
1257
|
+
return persisted if isinstance(persisted, dict) and persisted.get("head") == head else None
|
|
1258
|
+
|
|
1259
|
+
state["unrequested_base_merge"] = None # cleared unless proven below
|
|
1260
|
+
if "head_parents" not in cur:
|
|
1261
|
+
# Pre-detector fixture or persisted snapshot: no parent evidence exists to wait for.
|
|
1262
|
+
state["unrequested_base_merge_pending"] = None
|
|
1263
|
+
return None
|
|
1264
|
+
parents = cur.get("head_parents")
|
|
1265
|
+
if parents is None:
|
|
1266
|
+
state["unrequested_base_merge_pending"] = head
|
|
1267
|
+
return None
|
|
1268
|
+
state["unrequested_base_merge_pending"] = None
|
|
1269
|
+
if not isinstance(parents, list) or len(parents) != 2:
|
|
1270
|
+
return None
|
|
1271
|
+
base = cur.get("base") if isinstance(cur.get("base"), dict) else {}
|
|
1272
|
+
base_oids = {
|
|
1273
|
+
str(o).lower() for o in (
|
|
1274
|
+
base.get("oid"), base.get("graphql_oid"), base.get("historical_oid"),
|
|
1275
|
+
prior_base.get("oid"), prior_base.get("graphql_oid"))
|
|
1276
|
+
if _valid_oid(o)
|
|
1277
|
+
}
|
|
1278
|
+
base_parent = next((p for p in parents if p in base_oids), None)
|
|
1279
|
+
if base_parent is None:
|
|
1280
|
+
return None
|
|
1281
|
+
# Only a consumed mutation that produced THIS merge exempts it: the claimed item must name both
|
|
1282
|
+
# parents — its head is the merge's other parent and its base is the base parent. A confirmed
|
|
1283
|
+
# item for an earlier head, or for an older base, is not evidence.
|
|
1284
|
+
other_parents = {p for p in parents if p != base_parent}
|
|
1285
|
+
for item in (_load_branch_currency_state(state).get("items") or {}).values():
|
|
1286
|
+
if (isinstance(item, dict) and item.get("mutation_consumed")
|
|
1287
|
+
and str(item.get("head_sha") or "").lower() in other_parents
|
|
1288
|
+
and str(item.get("base_oid") or "").lower() == base_parent):
|
|
1289
|
+
return None
|
|
1290
|
+
fact = {"head": head, "base_parent": base_parent, "observed_at": _iso(now)}
|
|
1291
|
+
state["unrequested_base_merge"] = fact
|
|
1292
|
+
return fact
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def _prepare_open_currency_item(state, currency, key, item, now):
|
|
1296
|
+
parks = currency.get("semantic_parks") or {}
|
|
1297
|
+
answer_required = _currency_mutation_requires_answer(state, key, item)
|
|
1298
|
+
inspection_required = (item.get("status") == "DIRTY"
|
|
1299
|
+
and bool(parks)
|
|
1300
|
+
and item.get("inspection_result") != "changed"
|
|
1301
|
+
and not _answered_human_source_matches(
|
|
1302
|
+
state, "currency", key, _currency_source_observation(item)))
|
|
1303
|
+
item["inspection_required"] = inspection_required
|
|
1304
|
+
retry_wait_seconds = 0
|
|
1305
|
+
retry_not_before = item.get("retry_not_before")
|
|
1306
|
+
if retry_not_before:
|
|
1307
|
+
try:
|
|
1308
|
+
retry_wait_seconds = max(
|
|
1309
|
+
0, int((_parse_iso8601(retry_not_before) - now).total_seconds()))
|
|
1310
|
+
except (ValueError, TypeError):
|
|
1311
|
+
retry_wait_seconds = 0
|
|
1312
|
+
item["retry_wait_seconds"] = retry_wait_seconds
|
|
1313
|
+
if answer_required:
|
|
1314
|
+
item["attention"] = CURRENCY_ATTENTION_DECIDE
|
|
1315
|
+
elif inspection_required:
|
|
1316
|
+
item["attention"] = CURRENCY_ATTENTION_INSPECT
|
|
1317
|
+
elif retry_wait_seconds > 0:
|
|
1318
|
+
item["attention"] = None
|
|
1319
|
+
else:
|
|
1320
|
+
item["attention"] = CURRENCY_ATTENTION_CLAIM
|
|
1321
|
+
item["reconciliation_only"] = False
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _apply_branch_currency(state, cur, head, head_changed, now):
|
|
1325
|
+
currency = _load_branch_currency_state(state)
|
|
1326
|
+
carried_claims = _claimed_currency_items(currency)
|
|
1327
|
+
carried_semantic_parks = dict(currency.get("semantic_parks") or {})
|
|
1328
|
+
if head_changed or (currency.get("head_sha") not in (None, head)):
|
|
1329
|
+
currency = _empty_branch_currency_state()
|
|
1330
|
+
currency["items"] = dict(carried_claims)
|
|
1331
|
+
currency["semantic_parks"] = carried_semantic_parks
|
|
1332
|
+
state["branch_currency_state"] = currency
|
|
1333
|
+
currency["head_sha"] = head
|
|
1334
|
+
|
|
1335
|
+
# An unresolved claim owns the branch-currency lane until it is explicitly reconciled. A base
|
|
1336
|
+
# move or expected head move may be the mutation's result, so a new observation must not replace
|
|
1337
|
+
# the old exact key or make the PR look clear in the meantime.
|
|
1338
|
+
claimed_items = _claimed_currency_items(currency)
|
|
1339
|
+
if claimed_items:
|
|
1340
|
+
key, item = claimed_items[0]
|
|
1341
|
+
currency["current_key"] = key
|
|
1342
|
+
return _prepare_claimed_currency_item(state, item, cur, head)
|
|
1343
|
+
|
|
1344
|
+
observation = _branch_currency_observation(cur, head)
|
|
1345
|
+
if observation is None:
|
|
1346
|
+
currency["current_key"] = None
|
|
1347
|
+
return None
|
|
1348
|
+
|
|
1349
|
+
key = observation["key"]
|
|
1350
|
+
items = currency["items"]
|
|
1351
|
+
item = items.get(key)
|
|
1352
|
+
if not isinstance(item, dict):
|
|
1353
|
+
item = {**observation, "disposition": DISPOSITION_OPEN}
|
|
1354
|
+
else:
|
|
1355
|
+
# Refresh non-identity facts without changing an invocation-fenced disposition.
|
|
1356
|
+
item.update(observation)
|
|
1357
|
+
item["host_branch_update_capability"] = cur.get(
|
|
1358
|
+
"host_branch_update_capability", "unknown")
|
|
1359
|
+
parks = currency.get("semantic_parks") or {}
|
|
1360
|
+
item["parked_semantic_fingerprints"] = sorted(parks)
|
|
1361
|
+
item.setdefault("retry_count", 0)
|
|
1362
|
+
item.setdefault("mutation_consumed", False)
|
|
1363
|
+
item.setdefault("mutation_requires_answer", False)
|
|
1364
|
+
disposition = item.get("disposition", DISPOSITION_OPEN)
|
|
1365
|
+
if disposition == DISPOSITION_OPEN:
|
|
1366
|
+
_prepare_open_currency_item(state, currency, key, item, now)
|
|
1367
|
+
elif disposition == CURRENCY_CLAIMED:
|
|
1368
|
+
_prepare_claimed_currency_item(state, item, cur, head)
|
|
1369
|
+
else:
|
|
1370
|
+
item["attention"] = None
|
|
1371
|
+
item["reconciliation_only"] = False
|
|
1372
|
+
items[key] = item
|
|
1373
|
+
currency["current_key"] = key
|
|
1374
|
+
|
|
1375
|
+
# Old observations are useful only when they carry an unresolved mutation claim.
|
|
1376
|
+
retained = {item_key: value for item_key, value in items.items()
|
|
1377
|
+
if item_key == key
|
|
1378
|
+
or value.get("disposition") == CURRENCY_CLAIMED}
|
|
1379
|
+
currency["items"] = retained
|
|
1380
|
+
return item
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def _empty_trajectory():
|
|
1384
|
+
"""Deterministic cross-tick facts babysit hands the leaves (facts, not judgment):
|
|
1385
|
+
babysit ships the trajectory, the leaf decides whether it means non-convergence."""
|
|
1386
|
+
return {
|
|
1387
|
+
"check_history": {}, # check key -> {state, last_head, recur, seen_tick}
|
|
1388
|
+
"seen_threads": {}, # unresolved thread id -> first-seen tick
|
|
1389
|
+
"unresolved_series": [], # unresolved-thread count per tick (last 6)
|
|
1390
|
+
"stream_series": [], # single-stream activity per tick (last 8)
|
|
1391
|
+
"problem_keys": [], # last tick's failing checks + non-parked threads (progress detection)
|
|
1392
|
+
"min_open_problems": None, # lowest total open-problem count seen
|
|
1393
|
+
"heads_since_progress": 0, # head changes since progress (a new low OR something cleared)
|
|
1394
|
+
"last_head": None, # head as of the last AGENT tick — hsp counts moves between ticks,
|
|
1395
|
+
# NOT poll-observed head moves (state["head_sha"] advances on polls)
|
|
1396
|
+
"invariant_heads": {}, # resolver-supplied invariant_key -> unique heads it was marked on
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
def _load_trajectory(state):
|
|
1401
|
+
"""Load the trajectory, tolerating a partial or non-dict value from an older on-disk
|
|
1402
|
+
state.json (persisted in /tmp across script versions) — backfill missing keys so a new
|
|
1403
|
+
field never KeyErrors an old state, and a null/garbage value never crashes."""
|
|
1404
|
+
tj = state.get("trajectory")
|
|
1405
|
+
if not isinstance(tj, dict):
|
|
1406
|
+
tj = {}
|
|
1407
|
+
for key, value in _empty_trajectory().items():
|
|
1408
|
+
tj.setdefault(key, value)
|
|
1409
|
+
state["trajectory"] = tj
|
|
1410
|
+
return tj
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
_INVARIANT_KEY_RE = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _record_invariant_round(state, key, head):
|
|
1417
|
+
"""Count a resolver-supplied invariant against unique heads. Never infer keys."""
|
|
1418
|
+
if not key:
|
|
1419
|
+
return
|
|
1420
|
+
if not _INVARIANT_KEY_RE.match(key):
|
|
1421
|
+
raise SystemExit("--invariant-key must be 1-120 chars of A-Za-z0-9._:-")
|
|
1422
|
+
if not head:
|
|
1423
|
+
return
|
|
1424
|
+
tj = _load_trajectory(state)
|
|
1425
|
+
heads = tj.setdefault("invariant_heads", {})
|
|
1426
|
+
seen = heads.setdefault(key, [])
|
|
1427
|
+
if not isinstance(seen, list):
|
|
1428
|
+
seen = []
|
|
1429
|
+
heads[key] = seen
|
|
1430
|
+
if head not in seen:
|
|
1431
|
+
seen.append(head)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _invariant_rounds_view(tj):
|
|
1435
|
+
"""Public trajectory view: resolver keys counted per unique head."""
|
|
1436
|
+
items = []
|
|
1437
|
+
for key, heads in sorted((tj.get("invariant_heads") or {}).items()):
|
|
1438
|
+
if isinstance(heads, list):
|
|
1439
|
+
items.append({"key": key, "rounds": len(heads)})
|
|
1440
|
+
return items
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def _push_bounded(lst, item, cap):
|
|
1444
|
+
"""Append to a sliding window that keeps only the last `cap` items."""
|
|
1445
|
+
lst.append(item)
|
|
1446
|
+
del lst[:-cap]
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _stream_alternations(series):
|
|
1450
|
+
"""Count flips between ci-active and review-active ticks — the cross-stream churn signal."""
|
|
1451
|
+
flips = 0
|
|
1452
|
+
prev = None
|
|
1453
|
+
for s in series:
|
|
1454
|
+
if prev is not None and s != prev:
|
|
1455
|
+
flips += 1
|
|
1456
|
+
prev = s
|
|
1457
|
+
return flips
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def _trend(series):
|
|
1461
|
+
if len(series) < 3:
|
|
1462
|
+
return "flat"
|
|
1463
|
+
if series[-1] > series[0]:
|
|
1464
|
+
return "rising"
|
|
1465
|
+
if series[-1] < series[0]:
|
|
1466
|
+
return "falling"
|
|
1467
|
+
return "flat"
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _record_check_history(state, head, new_checks):
|
|
1471
|
+
"""Record CI fail->clear->fail recurrence transitions. Runs on EVERY snapshot — agent ticks AND
|
|
1472
|
+
watch polls — so a CLEAR (or FAIL) observed only between agent ticks is not lost, which would
|
|
1473
|
+
otherwise make the ping-pong recurrence trigger under-fire under the self-sustaining watch.
|
|
1474
|
+
Idempotent per transition: once a check is FAILING, re-observing FAIL does not re-increment."""
|
|
1475
|
+
tj = _load_trajectory(state)
|
|
1476
|
+
tick = state.get("tick", 0)
|
|
1477
|
+
hist = tj["check_history"]
|
|
1478
|
+
for key, c in new_checks.items():
|
|
1479
|
+
h = hist.setdefault(key, {"state": CHECK_UNKNOWN, "last_head": None, "recur": 0, "seen_tick": tick})
|
|
1480
|
+
h["seen_tick"] = tick
|
|
1481
|
+
if c["conclusion"] in FAILING:
|
|
1482
|
+
if h["state"] == CHECK_CLEAR and h["last_head"] != head: # fail after a clear on a new head
|
|
1483
|
+
h["recur"] += 1
|
|
1484
|
+
h["state"] = CHECK_FAILING
|
|
1485
|
+
h["last_head"] = head
|
|
1486
|
+
elif c["status"] == "COMPLETED": # observed non-failing terminal — a genuine clear
|
|
1487
|
+
h["state"] = CHECK_CLEAR
|
|
1488
|
+
h["last_head"] = head
|
|
1489
|
+
# IN_PROGRESS/QUEUED: leave prior state untouched (not yet a clear)
|
|
1490
|
+
# Evict entries unseen for TTL agent-ticks (polls don't advance the tick) — bounds growth without
|
|
1491
|
+
# erasing a check that was briefly absent (a one-tick gap must not lose real recurrence history).
|
|
1492
|
+
tj["check_history"] = {k: v for k, v in hist.items() if tick - v.get("seen_tick", tick) <= CHECK_HISTORY_TTL}
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _update_trajectory(state, head, new_checks, new_threads, new_feedback, actionable,
|
|
1496
|
+
decision_coverage=None):
|
|
1497
|
+
"""Maintain and emit the deterministic trajectory. Coarse by design: check-name-level
|
|
1498
|
+
recurrence, backlog trend, cross-stream alternation, no-progress heads. Fine, invariant-
|
|
1499
|
+
level judgment (log signatures, nit root-clustering) is the leaf's job, not this script's.
|
|
1500
|
+
`actionable` is the `{ci, threads, comments}` set diff() also returns. Non-thread feedback
|
|
1501
|
+
(top-level comments + review bodies) counts as review-stream activity and as an open problem
|
|
1502
|
+
for the stall signal, but the thread-named backlog fields stay scoped to inline threads."""
|
|
1503
|
+
tj = _load_trajectory(state)
|
|
1504
|
+
|
|
1505
|
+
# --- CI recurrence (fail->clear->fail on a *different* head): recorded on EVERY observation
|
|
1506
|
+
# (agent ticks AND watch polls, via _record_check_history in diff) so a clear seen only between
|
|
1507
|
+
# agent ticks is not lost. Here we just READ the accumulated history for the trigger. recur_max
|
|
1508
|
+
# reflects only checks present this tick, so a stale key can't keep it elevated. ---
|
|
1509
|
+
hist = tj["check_history"]
|
|
1510
|
+
recur_max = max((hist[k]["recur"] for k in new_checks if k in hist), default=0)
|
|
1511
|
+
recurring = [{"key": k, "recur": hist[k]["recur"]} for k in new_checks if k in hist and hist[k]["recur"] > 0]
|
|
1512
|
+
|
|
1513
|
+
# --- Review-thread backlog: trend of unresolved-thread count + genuinely new threads this
|
|
1514
|
+
# tick. Scoped to inline threads (non-thread feedback is captured in the total-problem stall
|
|
1515
|
+
# signal below), so these thread-named fields stay accurate. ---
|
|
1516
|
+
seen = tj.get("seen_threads", {})
|
|
1517
|
+
new_arrivals = [tid for tid in new_threads if tid not in seen]
|
|
1518
|
+
tj["seen_threads"] = {tid: seen.get(tid, state.get("tick", 0)) for tid in new_threads}
|
|
1519
|
+
_push_bounded(tj["unresolved_series"], len(new_threads), 6)
|
|
1520
|
+
|
|
1521
|
+
# --- Cross-stream churn: alternation between ci-only and review-only active ticks.
|
|
1522
|
+
# Review is active when either threads OR non-thread feedback is actionable. ---
|
|
1523
|
+
review_active = bool(actionable["threads"] or actionable.get("comments"))
|
|
1524
|
+
if actionable["ci"] and not review_active:
|
|
1525
|
+
active = STREAM_CI
|
|
1526
|
+
elif review_active and not actionable["ci"]:
|
|
1527
|
+
active = STREAM_REVIEW
|
|
1528
|
+
else:
|
|
1529
|
+
active = None # both or neither — not a single-stream tick, don't record
|
|
1530
|
+
if active:
|
|
1531
|
+
_push_bounded(tj["stream_series"], active, 8)
|
|
1532
|
+
|
|
1533
|
+
# --- No-progress heads: measured from TOTAL open problems (failing checks + non-parked
|
|
1534
|
+
# unresolved threads + non-parked non-thread feedback), NOT the post-claim `actionable` set —
|
|
1535
|
+
# marking items dispatched shrinks
|
|
1536
|
+
# actionable and would fake progress. Reset the counter when the head moves and either the
|
|
1537
|
+
# total set a new low OR a previously-failing item cleared: progressive migration (A cleared
|
|
1538
|
+
# while B appears) is progress, not a stall, so it must not accrue heads_since_progress. ---
|
|
1539
|
+
# Only genuinely-OPEN items are unresolved work: a `dispatched` item is handled (a top-level
|
|
1540
|
+
# comment never drops out of the fetch, so counting it would keep heads_since_progress climbing
|
|
1541
|
+
# forever and falsely trip non-convergence on unrelated later work), and `needs-human` is parked.
|
|
1542
|
+
decision_coverage = decision_coverage or set()
|
|
1543
|
+
problem_keys = {f"c:{k}" for k, c in new_checks.items()
|
|
1544
|
+
if c["conclusion"] in FAILING and ("check", k) not in decision_coverage}
|
|
1545
|
+
problem_keys |= {f"t:{tid}" for tid, t in new_threads.items()
|
|
1546
|
+
if t.get("disposition") == DISPOSITION_OPEN
|
|
1547
|
+
and ("thread", tid) not in decision_coverage}
|
|
1548
|
+
problem_keys |= {f"m:{fid}" for fid, f in new_feedback.items()
|
|
1549
|
+
if f.get("disposition") == DISPOSITION_OPEN
|
|
1550
|
+
and (f.get("kind", "comment"), fid) not in decision_coverage}
|
|
1551
|
+
cleared_something = bool(set(tj.get("problem_keys", [])) - problem_keys)
|
|
1552
|
+
open_problems = len(problem_keys)
|
|
1553
|
+
minp = tj.get("min_open_problems")
|
|
1554
|
+
new_low = minp is None or open_problems < minp
|
|
1555
|
+
if new_low:
|
|
1556
|
+
tj["min_open_problems"] = open_problems
|
|
1557
|
+
# heads_since_progress counts head moves BETWEEN AGENT TICKS (tj["last_head"]), not poll-observed
|
|
1558
|
+
# moves — a watch poll advances state["head_sha"], so a plain head_changed would read False at the
|
|
1559
|
+
# agent's tick and starve this stall signal under the default self-sustaining watch.
|
|
1560
|
+
traj_head_moved = tj.get("last_head") is not None and head != tj.get("last_head")
|
|
1561
|
+
if new_low or cleared_something:
|
|
1562
|
+
tj["heads_since_progress"] = 0
|
|
1563
|
+
elif traj_head_moved:
|
|
1564
|
+
tj["heads_since_progress"] = tj.get("heads_since_progress", 0) + 1
|
|
1565
|
+
tj["problem_keys"] = sorted(problem_keys)
|
|
1566
|
+
tj["last_head"] = head
|
|
1567
|
+
|
|
1568
|
+
return {
|
|
1569
|
+
"recurring_checks": recurring,
|
|
1570
|
+
"check_recur_max": recur_max,
|
|
1571
|
+
"unresolved_threads": len(new_threads),
|
|
1572
|
+
"unresolved_series": list(tj["unresolved_series"]),
|
|
1573
|
+
"unresolved_trend": _trend(tj["unresolved_series"]),
|
|
1574
|
+
"new_threads_this_tick": len(new_arrivals),
|
|
1575
|
+
"stream_alternations": _stream_alternations(tj["stream_series"]),
|
|
1576
|
+
"heads_since_progress": tj["heads_since_progress"],
|
|
1577
|
+
"invariant_rounds": _invariant_rounds_view(tj),
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _apply_dispositions(items, id_key, prior, identity_fn=None,
|
|
1582
|
+
reactivate_dispositions=(DISPOSITION_DISPATCHED,)):
|
|
1583
|
+
"""Claim->act->confirm dedup for a review stream: an item stays actionable until `mark`
|
|
1584
|
+
records a non-open disposition (persisted in prior[id]['disposition']). Returns
|
|
1585
|
+
(persisted_by_id, actionable_list, 0).
|
|
1586
|
+
|
|
1587
|
+
When identity_fn is given, an item whose disposition is in `reactivate_dispositions` is
|
|
1588
|
+
*reactivated* — set back to open and re-actionized — once its last-comment / edit identity moves
|
|
1589
|
+
past the one we acted on. That acted-on identity is captured lazily on the first tick after our
|
|
1590
|
+
own reply, so the reply becomes the baseline and later reviewer activity reopens the item. This
|
|
1591
|
+
stops a dispatched-but-unresolved thread with fresh activity from being silenced.
|
|
1592
|
+
|
|
1593
|
+
The feedback stream excludes DISPATCHED from `reactivate_dispositions` deliberately: status
|
|
1594
|
+
bots rewrite their comment bodies on every push, so edit-keyed reactivation re-actionized
|
|
1595
|
+
handled bot comments forever and merge-ready could never fire (#1309). A brand-new comment is
|
|
1596
|
+
a new id and is always actionable. Human decisions are a separate coverage layer below; source
|
|
1597
|
+
dispositions remain ordinary open/dispatched facts."""
|
|
1598
|
+
persisted, actionable = {}, []
|
|
1599
|
+
for it in items:
|
|
1600
|
+
iid = it.get(id_key)
|
|
1601
|
+
if not iid:
|
|
1602
|
+
continue
|
|
1603
|
+
pri = prior.get(iid, {})
|
|
1604
|
+
disposition = pri.get("disposition", DISPOSITION_OPEN)
|
|
1605
|
+
acted_identity = pri.get("acted_identity")
|
|
1606
|
+
if disposition in reactivate_dispositions and identity_fn is not None:
|
|
1607
|
+
current_identity = identity_fn(it)
|
|
1608
|
+
if acted_identity is None:
|
|
1609
|
+
acted_identity = current_identity # first post-action observation: adopt as baseline
|
|
1610
|
+
elif current_identity != acted_identity:
|
|
1611
|
+
disposition = DISPOSITION_OPEN # a later human/reviewer reply past our baseline -> reactivate
|
|
1612
|
+
acted_identity = None
|
|
1613
|
+
if disposition == DISPOSITION_OPEN:
|
|
1614
|
+
actionable.append(it)
|
|
1615
|
+
rec = {**it, "disposition": disposition}
|
|
1616
|
+
if acted_identity is not None:
|
|
1617
|
+
rec["acted_identity"] = acted_identity
|
|
1618
|
+
persisted[iid] = rec
|
|
1619
|
+
return persisted, actionable, 0
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _needs_human_residual_error(residual):
|
|
1623
|
+
"""Return why a residual is malformed, or None when the full contract holds."""
|
|
1624
|
+
if not isinstance(residual, dict) or residual.get("type") != "needs-human":
|
|
1625
|
+
return "must be one typed needs-human object"
|
|
1626
|
+
sources = residual.get("sources")
|
|
1627
|
+
if not isinstance(sources, list) or not sources:
|
|
1628
|
+
return "sources must be a non-empty list"
|
|
1629
|
+
seen_sources = set()
|
|
1630
|
+
for source in sources:
|
|
1631
|
+
if not isinstance(source, dict):
|
|
1632
|
+
return "every source must be an object"
|
|
1633
|
+
source_id, kind = source.get("id"), source.get("kind")
|
|
1634
|
+
if not isinstance(source_id, str) or not source_id.strip():
|
|
1635
|
+
return "every source id must be a non-empty string"
|
|
1636
|
+
if kind not in NEEDS_HUMAN_SOURCE_KINDS:
|
|
1637
|
+
return "every source kind must be thread, comment, review, check, or currency"
|
|
1638
|
+
identity = (kind, source_id)
|
|
1639
|
+
if identity in seen_sources:
|
|
1640
|
+
return "sources must be unique"
|
|
1641
|
+
seen_sources.add(identity)
|
|
1642
|
+
|
|
1643
|
+
context = residual.get("decision_context")
|
|
1644
|
+
if not isinstance(context, dict):
|
|
1645
|
+
return "decision_context must be an object"
|
|
1646
|
+
for field in ("quoted_feedback", "investigation", "decision_reason"):
|
|
1647
|
+
if not isinstance(context.get(field), str) or not context[field].strip():
|
|
1648
|
+
return f"decision_context.{field} must be a non-empty string"
|
|
1649
|
+
options = context.get("options")
|
|
1650
|
+
if not isinstance(options, list) or not options:
|
|
1651
|
+
return "decision_context.options must be a non-empty list"
|
|
1652
|
+
for option in options:
|
|
1653
|
+
if not isinstance(option, dict):
|
|
1654
|
+
return "every decision_context option must be an object"
|
|
1655
|
+
for field in ("option", "tradeoff"):
|
|
1656
|
+
if not isinstance(option.get(field), str) or not option[field].strip():
|
|
1657
|
+
return f"every decision_context option.{field} must be a non-empty string"
|
|
1658
|
+
if "recommendation" not in context:
|
|
1659
|
+
return "decision_context.recommendation is required"
|
|
1660
|
+
recommendation = context.get("recommendation")
|
|
1661
|
+
if recommendation is not None and (
|
|
1662
|
+
not isinstance(recommendation, str) or not recommendation.strip()):
|
|
1663
|
+
return "decision_context.recommendation must be null or a non-empty string"
|
|
1664
|
+
|
|
1665
|
+
thread_urls = residual.get("thread_urls")
|
|
1666
|
+
if not isinstance(thread_urls, list) or any(
|
|
1667
|
+
not isinstance(url, str) or not url.strip() for url in thread_urls):
|
|
1668
|
+
return "thread_urls must be a list of non-empty strings"
|
|
1669
|
+
thread_sources = sum(source["kind"] == "thread" for source in sources)
|
|
1670
|
+
if len(set(thread_urls)) != len(thread_urls) or len(thread_urls) != thread_sources:
|
|
1671
|
+
return "thread_urls must contain exactly one unique URL for every thread source"
|
|
1672
|
+
return None
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _residual_identity(residual):
|
|
1676
|
+
return json.dumps(residual, sort_keys=True, separators=(",", ":"))
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def _source_identity(source):
|
|
1680
|
+
return source.get("kind"), source.get("id")
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def _check_observation_identity(check):
|
|
1684
|
+
if not isinstance(check, dict):
|
|
1685
|
+
return None
|
|
1686
|
+
return {
|
|
1687
|
+
"head_sha": check.get("head_sha"),
|
|
1688
|
+
"conclusion": check.get("conclusion"),
|
|
1689
|
+
"details_url": check.get("details_url"),
|
|
1690
|
+
"created_at": check.get("created_at"),
|
|
1691
|
+
"started_at": check.get("started_at"),
|
|
1692
|
+
"completed_at": check.get("completed_at"),
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _currency_source_observation(item):
|
|
1697
|
+
if not isinstance(item, dict):
|
|
1698
|
+
return None
|
|
1699
|
+
observation = {
|
|
1700
|
+
"key": item.get("key"),
|
|
1701
|
+
"status": item.get("status"),
|
|
1702
|
+
"semantic_conflict_fingerprint": item.get("semantic_conflict_fingerprint"),
|
|
1703
|
+
"recovery_state": item.get("recovery_state"),
|
|
1704
|
+
"mutation_requires_answer": bool(item.get("mutation_requires_answer", False)),
|
|
1705
|
+
}
|
|
1706
|
+
if item.get("status") == "BEHIND" and item.get("recovery_state") is None:
|
|
1707
|
+
observation["host_branch_update_capability"] = item.get(
|
|
1708
|
+
"host_branch_update_capability")
|
|
1709
|
+
return observation
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def _current_source_observation(state, kind, source_id, threads, feedback, checks):
|
|
1713
|
+
if kind == "thread":
|
|
1714
|
+
record = threads.get(source_id)
|
|
1715
|
+
return ([record.get("last_comment_id"), record.get("last_comment_at")]
|
|
1716
|
+
if isinstance(record, dict) else None)
|
|
1717
|
+
if kind in ("comment", "review"):
|
|
1718
|
+
record = feedback.get(source_id)
|
|
1719
|
+
if not isinstance(record, dict) or record.get("kind") != kind:
|
|
1720
|
+
return None
|
|
1721
|
+
return [record.get("edit_id")]
|
|
1722
|
+
if kind == "check":
|
|
1723
|
+
record = checks.get(source_id)
|
|
1724
|
+
if not isinstance(record, dict) or record.get("conclusion") not in FAILING:
|
|
1725
|
+
return None
|
|
1726
|
+
return _check_observation_identity(record)
|
|
1727
|
+
if kind == "currency":
|
|
1728
|
+
currency = _load_branch_currency_state(state)
|
|
1729
|
+
if currency.get("current_key") != source_id:
|
|
1730
|
+
return None
|
|
1731
|
+
return _currency_source_observation(currency.get("items", {}).get(source_id))
|
|
1732
|
+
return None
|
|
1733
|
+
|
|
1734
|
+
|
|
1735
|
+
def _decision_id(residual, sources):
|
|
1736
|
+
payload = json.dumps(
|
|
1737
|
+
{"residual": residual, "sources": sources}, sort_keys=True, separators=(",", ":"))
|
|
1738
|
+
return f"decision:{hashlib.sha256(payload.encode('utf-8')).hexdigest()}"
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
def _decision_record(residual, sources, created_at=None):
|
|
1742
|
+
return {
|
|
1743
|
+
"id": _decision_id(residual, sources),
|
|
1744
|
+
"residual": json.loads(json.dumps(residual)),
|
|
1745
|
+
"sources": json.loads(json.dumps(sources)),
|
|
1746
|
+
"created_at": created_at,
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def _install_human_decisions(state, decisions):
|
|
1751
|
+
"""Install canonical decisions while retaining currency mutation safety independently."""
|
|
1752
|
+
currency = _load_branch_currency_state(state)
|
|
1753
|
+
items = currency.get("items", {})
|
|
1754
|
+
for decision in decisions:
|
|
1755
|
+
if not isinstance(decision, dict):
|
|
1756
|
+
continue
|
|
1757
|
+
sources = decision.get("sources")
|
|
1758
|
+
if not isinstance(sources, list):
|
|
1759
|
+
continue
|
|
1760
|
+
for source in sources:
|
|
1761
|
+
if not isinstance(source, dict) or source.get("kind") != "currency":
|
|
1762
|
+
continue
|
|
1763
|
+
item = items.get(source.get("id"))
|
|
1764
|
+
if isinstance(item, dict):
|
|
1765
|
+
item["mutation_requires_answer"] = True
|
|
1766
|
+
observation = source.get("observation")
|
|
1767
|
+
if isinstance(observation, dict):
|
|
1768
|
+
observation["mutation_requires_answer"] = True
|
|
1769
|
+
if isinstance(decision.get("residual"), dict):
|
|
1770
|
+
decision["id"] = _decision_id(decision["residual"], sources)
|
|
1771
|
+
state["human_decisions"] = decisions
|
|
1772
|
+
|
|
1773
|
+
|
|
1774
|
+
def _decision_source_matches(source, kind, source_id, observation):
|
|
1775
|
+
return (isinstance(source, dict) and source.get("kind") == kind
|
|
1776
|
+
and source.get("id") == source_id and source.get("observation") == observation)
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _answered_human_source_matches(state, kind, source_id, observation):
|
|
1780
|
+
return any(
|
|
1781
|
+
_decision_source_matches(source, kind, source_id, observation)
|
|
1782
|
+
for decision in state.get("answered_human_decisions") or []
|
|
1783
|
+
if isinstance(decision, dict)
|
|
1784
|
+
for source in decision.get("sources") or []
|
|
1785
|
+
)
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def _currency_mutation_requires_answer(state, source_id, item):
|
|
1789
|
+
observation = _currency_source_observation(item)
|
|
1790
|
+
return (bool(item.get("mutation_requires_answer"))
|
|
1791
|
+
and not _answered_human_source_matches(
|
|
1792
|
+
state, "currency", source_id, observation))
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _source_is_actionable(state, kind, source_id, threads, feedback, checks):
|
|
1796
|
+
if kind == "thread":
|
|
1797
|
+
record = threads.get(source_id)
|
|
1798
|
+
return isinstance(record, dict) and record.get("disposition") == DISPOSITION_OPEN
|
|
1799
|
+
if kind in ("comment", "review"):
|
|
1800
|
+
record = feedback.get(source_id)
|
|
1801
|
+
return (isinstance(record, dict) and record.get("kind") == kind
|
|
1802
|
+
and record.get("disposition") == DISPOSITION_OPEN)
|
|
1803
|
+
if kind == "check":
|
|
1804
|
+
record = checks.get(source_id)
|
|
1805
|
+
head = state.get("head_sha")
|
|
1806
|
+
return (isinstance(record, dict) and record.get("conclusion") in FAILING
|
|
1807
|
+
and source_id not in state.get("ci_dispatched", {}).get(head, []))
|
|
1808
|
+
if kind == "currency":
|
|
1809
|
+
currency = _load_branch_currency_state(state)
|
|
1810
|
+
record = currency.get("items", {}).get(source_id)
|
|
1811
|
+
return (currency.get("current_key") == source_id and isinstance(record, dict)
|
|
1812
|
+
and record.get("disposition") in (DISPOSITION_OPEN, CURRENCY_CLAIMED))
|
|
1813
|
+
return False
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
def _source_can_enter_human_decision(state, kind, source_id, threads, feedback, checks):
|
|
1817
|
+
"""Accept exact current evidence even when ordinary dispatch already suppresses it."""
|
|
1818
|
+
if _current_source_observation(state, kind, source_id, threads, feedback, checks) is None:
|
|
1819
|
+
return False
|
|
1820
|
+
if kind != "currency":
|
|
1821
|
+
return True
|
|
1822
|
+
currency = _load_branch_currency_state(state)
|
|
1823
|
+
record = currency.get("items", {}).get(source_id)
|
|
1824
|
+
return (currency.get("current_key") == source_id and isinstance(record, dict)
|
|
1825
|
+
and record.get("disposition") in (DISPOSITION_OPEN, CURRENCY_CLAIMED))
|
|
1826
|
+
|
|
1827
|
+
|
|
1828
|
+
def _release_ordinary_suppression_for_decision(state, sources, threads, feedback):
|
|
1829
|
+
"""Make canonical decision coverage the sole suppression owner for review and CI."""
|
|
1830
|
+
head = state.get("head_sha")
|
|
1831
|
+
dispatched_checks = state.get("ci_dispatched", {}).get(head)
|
|
1832
|
+
for source in sources:
|
|
1833
|
+
kind, source_id = source.get("kind"), source.get("id")
|
|
1834
|
+
record = (threads.get(source_id) if kind == "thread"
|
|
1835
|
+
else feedback.get(source_id) if kind in ("comment", "review") else None)
|
|
1836
|
+
if isinstance(record, dict):
|
|
1837
|
+
record["disposition"] = DISPOSITION_OPEN
|
|
1838
|
+
record.pop("acted_identity", None)
|
|
1839
|
+
elif kind == "check" and isinstance(dispatched_checks, list):
|
|
1840
|
+
state["ci_dispatched"][head] = [
|
|
1841
|
+
key for key in state["ci_dispatched"][head] if key != source_id]
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
def _migrate_legacy_check_observation(state, source_id, legacy_observations, checks):
|
|
1845
|
+
legacy = legacy_observations.get(source_id)
|
|
1846
|
+
current = _current_source_observation(state, "check", source_id, {}, {}, checks)
|
|
1847
|
+
if (not isinstance(legacy, dict) or not isinstance(current, dict)
|
|
1848
|
+
or current.get("head_sha") != state.get("head_sha")
|
|
1849
|
+
or any(current.get(field) != value for field, value in legacy.items())):
|
|
1850
|
+
return None
|
|
1851
|
+
return current
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
def _retire_legacy_check_suppression(state, residual):
|
|
1855
|
+
if not isinstance(residual, dict) or not isinstance(residual.get("sources"), list):
|
|
1856
|
+
return
|
|
1857
|
+
head = state.get("head_sha")
|
|
1858
|
+
dispatched = state.get("ci_dispatched", {}).get(head)
|
|
1859
|
+
if not isinstance(dispatched, list):
|
|
1860
|
+
return
|
|
1861
|
+
legacy_check_ids = {
|
|
1862
|
+
source.get("id") for source in residual["sources"]
|
|
1863
|
+
if isinstance(source, dict) and source.get("kind") == "check"
|
|
1864
|
+
}
|
|
1865
|
+
state["ci_dispatched"][head] = [
|
|
1866
|
+
key for key in dispatched if key not in legacy_check_ids]
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
def _legacy_decision_records(state, threads, feedback, checks, now):
|
|
1870
|
+
"""Replace legacy suppression with exact current decisions; incomplete evidence fails open."""
|
|
1871
|
+
candidates = list(state.get("needs_human_residuals") or [])
|
|
1872
|
+
candidates.extend(
|
|
1873
|
+
record.get("needs_human_residual")
|
|
1874
|
+
for record in list(threads.values()) + list(feedback.values())
|
|
1875
|
+
if isinstance(record.get("needs_human_residual"), dict)
|
|
1876
|
+
)
|
|
1877
|
+
legacy_check_observations = state.get("needs_human_check_observations") or {}
|
|
1878
|
+
records, seen = [], set()
|
|
1879
|
+
for residual in candidates:
|
|
1880
|
+
_retire_legacy_check_suppression(state, residual)
|
|
1881
|
+
if _needs_human_residual_error(residual):
|
|
1882
|
+
continue
|
|
1883
|
+
identity = _residual_identity(residual)
|
|
1884
|
+
if identity in seen:
|
|
1885
|
+
continue
|
|
1886
|
+
seen.add(identity)
|
|
1887
|
+
sources = []
|
|
1888
|
+
for source in residual["sources"]:
|
|
1889
|
+
kind, source_id = _source_identity(source)
|
|
1890
|
+
observation = None
|
|
1891
|
+
record = (threads.get(source_id) if kind == "thread"
|
|
1892
|
+
else feedback.get(source_id) if kind in ("comment", "review") else None)
|
|
1893
|
+
if isinstance(record, dict) and record.get("acted_identity") is not None:
|
|
1894
|
+
observation = record.get("acted_identity")
|
|
1895
|
+
elif kind == "check":
|
|
1896
|
+
observation = _migrate_legacy_check_observation(
|
|
1897
|
+
state, source_id, legacy_check_observations, checks)
|
|
1898
|
+
else:
|
|
1899
|
+
observation = _current_source_observation(
|
|
1900
|
+
state, kind, source_id, threads, feedback, checks)
|
|
1901
|
+
if observation is None:
|
|
1902
|
+
sources = []
|
|
1903
|
+
break
|
|
1904
|
+
sources.append({"kind": kind, "id": source_id, "observation": observation})
|
|
1905
|
+
if sources:
|
|
1906
|
+
records.append(_decision_record(residual, sources, _iso(now)))
|
|
1907
|
+
return records
|
|
1908
|
+
|
|
1909
|
+
|
|
1910
|
+
def _legacy_answered_decision_records(state, threads, feedback, checks):
|
|
1911
|
+
"""Preserve only the exact source evidence carried by a legacy answer owner."""
|
|
1912
|
+
records, seen = [], set()
|
|
1913
|
+
currency = _load_branch_currency_state(state)
|
|
1914
|
+
for source_id, item in currency.get("items", {}).items():
|
|
1915
|
+
answered = item.get("answered_decision")
|
|
1916
|
+
if not isinstance(answered, dict):
|
|
1917
|
+
continue
|
|
1918
|
+
residual, answer = answered.get("residual"), answered.get("answer")
|
|
1919
|
+
_retire_legacy_check_suppression(state, residual)
|
|
1920
|
+
if _needs_human_residual_error(residual) or not isinstance(answer, str) or not answer.strip():
|
|
1921
|
+
continue
|
|
1922
|
+
owns_residual = any(
|
|
1923
|
+
_source_identity(source) == ("currency", source_id)
|
|
1924
|
+
for source in residual["sources"])
|
|
1925
|
+
observation = _current_source_observation(
|
|
1926
|
+
state, "currency", source_id, threads, feedback, checks)
|
|
1927
|
+
if not owns_residual or observation is None:
|
|
1928
|
+
continue
|
|
1929
|
+
sources = [{"kind": "currency", "id": source_id, "observation": observation}]
|
|
1930
|
+
record = {
|
|
1931
|
+
**_decision_record(residual, sources, answered.get("recorded_at")),
|
|
1932
|
+
"answer": answer.strip(),
|
|
1933
|
+
"answered_at": answered.get("recorded_at"),
|
|
1934
|
+
}
|
|
1935
|
+
if record["id"] not in seen:
|
|
1936
|
+
seen.add(record["id"])
|
|
1937
|
+
records.append(record)
|
|
1938
|
+
return records
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
def _normalize_legacy_source_state(state, threads, feedback):
|
|
1942
|
+
for records in (threads, feedback):
|
|
1943
|
+
for record in records.values():
|
|
1944
|
+
if record.get("disposition") == DISPOSITION_NEEDS_HUMAN:
|
|
1945
|
+
record["disposition"] = DISPOSITION_OPEN
|
|
1946
|
+
record.pop("needs_human_residual", None)
|
|
1947
|
+
if record.get("disposition") == DISPOSITION_OPEN:
|
|
1948
|
+
record.pop("acted_identity", None)
|
|
1949
|
+
currency = _load_branch_currency_state(state)
|
|
1950
|
+
for item in currency.get("items", {}).values():
|
|
1951
|
+
if item.get("disposition") == DISPOSITION_NEEDS_HUMAN:
|
|
1952
|
+
_reactivate_currency_item(currency, item)
|
|
1953
|
+
item.pop("requires_decision", None)
|
|
1954
|
+
item.pop("pending_decision", None)
|
|
1955
|
+
item.pop("answered_decision", None)
|
|
1956
|
+
state.pop("needs_human_residuals", None)
|
|
1957
|
+
state.pop("needs_human_check_observations", None)
|
|
1958
|
+
|
|
1959
|
+
|
|
1960
|
+
def _human_decision_identity(decision):
|
|
1961
|
+
if not isinstance(decision, dict):
|
|
1962
|
+
return None
|
|
1963
|
+
residual, sources = decision.get("residual"), decision.get("sources")
|
|
1964
|
+
if not isinstance(residual, dict) or not isinstance(sources, list):
|
|
1965
|
+
return None
|
|
1966
|
+
return _decision_id(residual, sources)
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def _replace_human_decisions(
|
|
1970
|
+
state, kept, previous=None, threads=None, feedback=None, checks=None):
|
|
1971
|
+
"""Replace canonical decisions and reactivate exact surviving source evidence."""
|
|
1972
|
+
previous = previous if isinstance(previous, list) else state.get("human_decisions")
|
|
1973
|
+
previous = previous if isinstance(previous, list) else []
|
|
1974
|
+
kept_identities = {
|
|
1975
|
+
identity for decision in kept
|
|
1976
|
+
if (identity := _human_decision_identity(decision)) is not None
|
|
1977
|
+
}
|
|
1978
|
+
removed = [
|
|
1979
|
+
decision for decision in previous
|
|
1980
|
+
if (_human_decision_identity(decision) not in kept_identities)
|
|
1981
|
+
]
|
|
1982
|
+
_install_human_decisions(state, kept)
|
|
1983
|
+
|
|
1984
|
+
threads = threads if isinstance(threads, dict) else state.get("threads", {})
|
|
1985
|
+
feedback = feedback if isinstance(feedback, dict) else state.get("feedback", {})
|
|
1986
|
+
checks = checks if isinstance(checks, dict) else state.get("checks", {})
|
|
1987
|
+
for decision in removed:
|
|
1988
|
+
if not isinstance(decision, dict):
|
|
1989
|
+
continue
|
|
1990
|
+
for source in decision.get("sources") or []:
|
|
1991
|
+
if not isinstance(source, dict):
|
|
1992
|
+
continue
|
|
1993
|
+
kind, source_id = source.get("kind"), source.get("id")
|
|
1994
|
+
current = _current_source_observation(
|
|
1995
|
+
state, kind, source_id, threads, feedback, checks)
|
|
1996
|
+
if current != source.get("observation") or kind != "currency":
|
|
1997
|
+
continue
|
|
1998
|
+
# Review and CI retain ordinary open state; currency has an explicit claim to clear.
|
|
1999
|
+
currency = _load_branch_currency_state(state)
|
|
2000
|
+
item = currency.get("items", {}).get(source_id)
|
|
2001
|
+
if isinstance(item, dict):
|
|
2002
|
+
_reactivate_currency_item(currency, item)
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
def _reconcile_human_decisions(state, threads, feedback, checks, now):
|
|
2006
|
+
"""Let canonical decisions alone suppress exact evidence; activity is never an answer."""
|
|
2007
|
+
current = state.get("human_decisions")
|
|
2008
|
+
if not isinstance(current, list):
|
|
2009
|
+
current = _legacy_decision_records(state, threads, feedback, checks, now)
|
|
2010
|
+
elif not current and state.get("needs_human_residuals"):
|
|
2011
|
+
current = _legacy_decision_records(state, threads, feedback, checks, now)
|
|
2012
|
+
_install_human_decisions(state, current)
|
|
2013
|
+
answered = state.get("answered_human_decisions")
|
|
2014
|
+
if not isinstance(answered, list):
|
|
2015
|
+
answered = _legacy_answered_decision_records(
|
|
2016
|
+
state, threads, feedback, checks)
|
|
2017
|
+
elif not answered:
|
|
2018
|
+
answered = _legacy_answered_decision_records(
|
|
2019
|
+
state, threads, feedback, checks)
|
|
2020
|
+
_normalize_legacy_source_state(state, threads, feedback)
|
|
2021
|
+
|
|
2022
|
+
kept, seen = [], set()
|
|
2023
|
+
for decision in current:
|
|
2024
|
+
if not isinstance(decision, dict) or _needs_human_residual_error(decision.get("residual")):
|
|
2025
|
+
continue
|
|
2026
|
+
sources = decision.get("sources")
|
|
2027
|
+
if not isinstance(sources, list) or len(sources) != len(decision["residual"]["sources"]):
|
|
2028
|
+
continue
|
|
2029
|
+
expected = {(source.get("kind"), source.get("id")) for source in sources}
|
|
2030
|
+
if expected != {_source_identity(source) for source in decision["residual"]["sources"]}:
|
|
2031
|
+
continue
|
|
2032
|
+
if not all(
|
|
2033
|
+
source.get("observation") is not None
|
|
2034
|
+
and _current_source_observation(
|
|
2035
|
+
state, source.get("kind"), source.get("id"), threads, feedback, checks)
|
|
2036
|
+
== source.get("observation")
|
|
2037
|
+
for source in sources):
|
|
2038
|
+
continue
|
|
2039
|
+
normalized = _decision_record(decision["residual"], sources, decision.get("created_at"))
|
|
2040
|
+
if normalized["id"] in seen:
|
|
2041
|
+
continue
|
|
2042
|
+
seen.add(normalized["id"])
|
|
2043
|
+
kept.append(normalized)
|
|
2044
|
+
|
|
2045
|
+
_replace_human_decisions(
|
|
2046
|
+
state, kept, current, threads=threads, feedback=feedback, checks=checks)
|
|
2047
|
+
kept_answers = []
|
|
2048
|
+
for decision in answered:
|
|
2049
|
+
if not isinstance(decision, dict) or not isinstance(decision.get("answer"), str):
|
|
2050
|
+
continue
|
|
2051
|
+
sources = decision.get("sources") or []
|
|
2052
|
+
matching_actionable = any(
|
|
2053
|
+
_current_source_observation(
|
|
2054
|
+
state, source.get("kind"), source.get("id"), threads, feedback, checks)
|
|
2055
|
+
== source.get("observation")
|
|
2056
|
+
and _source_is_actionable(
|
|
2057
|
+
state, source.get("kind"), source.get("id"), threads, feedback, checks)
|
|
2058
|
+
for source in sources if isinstance(source, dict))
|
|
2059
|
+
if matching_actionable:
|
|
2060
|
+
kept_answers.append(decision)
|
|
2061
|
+
|
|
2062
|
+
state["answered_human_decisions"] = kept_answers
|
|
2063
|
+
covered = {
|
|
2064
|
+
(source["kind"], source["id"])
|
|
2065
|
+
for decision in kept for source in decision["sources"]
|
|
2066
|
+
}
|
|
2067
|
+
public = []
|
|
2068
|
+
for decision in kept:
|
|
2069
|
+
public.append(json.loads(json.dumps(decision["residual"])))
|
|
2070
|
+
return public, covered
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def diff(state, cur, now=None, advance_trajectory=True):
|
|
2074
|
+
"""Pure: given prior state + current snapshot, compute the actionable set
|
|
2075
|
+
and the persisted observed state. `now` is injectable for tests."""
|
|
2076
|
+
now = now or _now()
|
|
2077
|
+
# A transient null/empty head (a gh hiccup) falls back to the last known head,
|
|
2078
|
+
# so a momentary null does not look like a new head and wipe ci_dispatched.
|
|
2079
|
+
head = cur["head_sha"] or state.get("head_sha")
|
|
2080
|
+
head_changed = state.get("head_sha") is not None and head != state["head_sha"]
|
|
2081
|
+
_apply_stale_merge_computation(state, cur, head, now)
|
|
2082
|
+
|
|
2083
|
+
prior_threads = state.get("threads", {})
|
|
2084
|
+
prior_feedback = state.get("feedback", {})
|
|
2085
|
+
prior_feedback_activity = {
|
|
2086
|
+
fid: item.get("edit_id") for fid, item in prior_feedback.items()
|
|
2087
|
+
}
|
|
2088
|
+
prior_review_decision = state.get("review_decision")
|
|
2089
|
+
prior_change_sig = _change_sig(state)
|
|
2090
|
+
if head_changed:
|
|
2091
|
+
# SHA-scoped state is meaningless on a new head.
|
|
2092
|
+
state["ci_dispatched"] = {}
|
|
2093
|
+
|
|
2094
|
+
# --- CI: a failing check on the current head is actionable until the agent
|
|
2095
|
+
# marks it dispatched (recorded in ci_dispatched[head]) or the head moves.
|
|
2096
|
+
# `checks_terminal` = every check has finished (none IN_PROGRESS/QUEUED).
|
|
2097
|
+
# Duplicate check keys (same workflow/name) are disambiguated with a #n suffix
|
|
2098
|
+
# so one never shadows another and silently drops a failing check. ---
|
|
2099
|
+
new_checks = {}
|
|
2100
|
+
failing_check_items = {}
|
|
2101
|
+
has_failing = False
|
|
2102
|
+
checks_terminal = True
|
|
2103
|
+
seen_keys = {}
|
|
2104
|
+
for c in cur["checks"]:
|
|
2105
|
+
key = c["key"]
|
|
2106
|
+
if key in seen_keys:
|
|
2107
|
+
seen_keys[key] += 1
|
|
2108
|
+
key = f"{key}#{seen_keys[key]}"
|
|
2109
|
+
else:
|
|
2110
|
+
seen_keys[key] = 0
|
|
2111
|
+
new_checks[key] = {
|
|
2112
|
+
"name": c["name"], "status": c["status"],
|
|
2113
|
+
"conclusion": c["conclusion"], "head_sha": head,
|
|
2114
|
+
"details_url": c.get("details_url"),
|
|
2115
|
+
"created_at": c.get("created_at"),
|
|
2116
|
+
"started_at": c.get("started_at"),
|
|
2117
|
+
"completed_at": c.get("completed_at"),
|
|
2118
|
+
}
|
|
2119
|
+
if c["status"] != "COMPLETED":
|
|
2120
|
+
checks_terminal = False
|
|
2121
|
+
if c["conclusion"] in FAILING:
|
|
2122
|
+
has_failing = True
|
|
2123
|
+
failing_check_items[key] = {
|
|
2124
|
+
"key": key, "name": c["name"], "conclusion": c["conclusion"],
|
|
2125
|
+
"details_url": c["details_url"],
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
# Review streams keep only ordinary open/dispatched state. Human decisions are a separate
|
|
2129
|
+
# exact-observation coverage layer reconciled below, so a remote edit invalidates a question
|
|
2130
|
+
# but is never mistaken for the user's answer.
|
|
2131
|
+
new_threads, _, _ = _apply_dispositions(
|
|
2132
|
+
cur["threads"], "thread_id", prior_threads,
|
|
2133
|
+
identity_fn=lambda t: [t.get("last_comment_id"), t.get("last_comment_at")])
|
|
2134
|
+
new_feedback, _, _ = _apply_dispositions(
|
|
2135
|
+
cur.get("feedback") or [], "id", prior_feedback,
|
|
2136
|
+
identity_fn=lambda c: [c.get("edit_id")], reactivate_dispositions=())
|
|
2137
|
+
# Reconcile currency before exact decision observations are compared.
|
|
2138
|
+
branch_currency = _apply_branch_currency(state, cur, head, head_changed, now)
|
|
2139
|
+
needs_human_residuals, decision_coverage = _reconcile_human_decisions(
|
|
2140
|
+
state, new_threads, new_feedback, new_checks, now)
|
|
2141
|
+
dispatched = set(state.get("ci_dispatched", {}).get(head, []))
|
|
2142
|
+
# Every attention stream is derived from ordinary state minus current decision coverage.
|
|
2143
|
+
actionable_threads = [
|
|
2144
|
+
record for source_id, record in new_threads.items()
|
|
2145
|
+
if record.get("disposition") == DISPOSITION_OPEN
|
|
2146
|
+
and ("thread", source_id) not in decision_coverage]
|
|
2147
|
+
actionable_feedback = [
|
|
2148
|
+
record for source_id, record in new_feedback.items()
|
|
2149
|
+
if record.get("disposition") == DISPOSITION_OPEN
|
|
2150
|
+
and (record.get("kind", "comment"), source_id) not in decision_coverage]
|
|
2151
|
+
actionable_ci = [
|
|
2152
|
+
item for key, item in failing_check_items.items()
|
|
2153
|
+
if key not in dispatched and ("check", key) not in decision_coverage]
|
|
2154
|
+
current_thread_activity = {
|
|
2155
|
+
item.get("thread_id"): _thread_identity(item)
|
|
2156
|
+
for item in cur["threads"] if item.get("thread_id")
|
|
2157
|
+
}
|
|
2158
|
+
prior_thread_activity = {
|
|
2159
|
+
tid: _prior_thread_activity_identity(item) for tid, item in prior_threads.items()
|
|
2160
|
+
if not (item.get("disposition") == DISPOSITION_DISPATCHED
|
|
2161
|
+
and tid not in current_thread_activity)
|
|
2162
|
+
}
|
|
2163
|
+
current_feedback_activity = {
|
|
2164
|
+
item.get("id"): item.get("edit_id")
|
|
2165
|
+
for item in (cur.get("feedback") or []) if item.get("id")
|
|
2166
|
+
}
|
|
2167
|
+
external_review_moved = (
|
|
2168
|
+
head_changed
|
|
2169
|
+
or current_thread_activity != prior_thread_activity
|
|
2170
|
+
or current_feedback_activity != prior_feedback_activity
|
|
2171
|
+
or cur.get("review_decision") != prior_review_decision
|
|
2172
|
+
)
|
|
2173
|
+
open_needs_human = len(needs_human_residuals)
|
|
2174
|
+
# Compatibility view for consumers that display source identities. The canonical set above,
|
|
2175
|
+
# not this flattened list, owns persistence and stop behavior.
|
|
2176
|
+
needs_human_ids = sorted({
|
|
2177
|
+
source["id"] for residual in needs_human_residuals for source in residual["sources"]})
|
|
2178
|
+
|
|
2179
|
+
state["head_sha"] = head or state.get("head_sha")
|
|
2180
|
+
state["checks"] = new_checks
|
|
2181
|
+
state["threads"] = new_threads
|
|
2182
|
+
state["feedback"] = new_feedback
|
|
2183
|
+
state["review_decision"] = cur["review_decision"]
|
|
2184
|
+
state["mergeable"] = cur["mergeable"]
|
|
2185
|
+
state["merge_state_status"] = cur["merge_state_status"]
|
|
2186
|
+
observed_awaiting_approval = cur.get("awaiting_approval")
|
|
2187
|
+
approval_probe_succeeded = observed_awaiting_approval is not None
|
|
2188
|
+
awaiting_approval = (
|
|
2189
|
+
observed_awaiting_approval
|
|
2190
|
+
if approval_probe_succeeded
|
|
2191
|
+
else state.get("awaiting_approval", 0)
|
|
2192
|
+
)
|
|
2193
|
+
state["awaiting_approval"] = awaiting_approval
|
|
2194
|
+
if awaiting_approval > 0:
|
|
2195
|
+
gate_is_new_for_head = (
|
|
2196
|
+
state.get("blocked_external_head_sha") != head
|
|
2197
|
+
or not state.get("blocked_external_review_last_activity_at")
|
|
2198
|
+
)
|
|
2199
|
+
if gate_is_new_for_head:
|
|
2200
|
+
state["blocked_external_head_sha"] = head
|
|
2201
|
+
state["blocked_external_first_seen_at"] = _iso(now)
|
|
2202
|
+
state["blocked_external_review_last_activity_at"] = _iso(now)
|
|
2203
|
+
elif external_review_moved:
|
|
2204
|
+
state["blocked_external_review_last_activity_at"] = _iso(now)
|
|
2205
|
+
elif approval_probe_succeeded:
|
|
2206
|
+
state["blocked_external_head_sha"] = None
|
|
2207
|
+
state["blocked_external_first_seen_at"] = None
|
|
2208
|
+
state["blocked_external_review_last_activity_at"] = None
|
|
2209
|
+
state["pr_chain"] = cur.get("pr_chain") or _default_pr_chain()
|
|
2210
|
+
unrequested_base_merge = _detect_unrequested_base_merge(state, cur, head, head_changed, now)
|
|
2211
|
+
unrequested_base_merge_pending = state.get("unrequested_base_merge_pending") == head
|
|
2212
|
+
if unrequested_base_merge_pending and isinstance(cur.get("base"), dict) \
|
|
2213
|
+
and _base_ref_blocker(cur["base"]) is None:
|
|
2214
|
+
# An unclassified head is a pending merge computation: whatever else the base identity says
|
|
2215
|
+
# (current or a bounded stale-computation degrade), it is not certain, not ready, not landable.
|
|
2216
|
+
cur["base"]["identity"] = BASE_REF_MERGEABILITY_PENDING
|
|
2217
|
+
state["base"] = cur.get("base")
|
|
2218
|
+
if branch_currency and branch_currency.get("disposition") == DISPOSITION_OPEN:
|
|
2219
|
+
_prepare_open_currency_item(
|
|
2220
|
+
state, _load_branch_currency_state(state), branch_currency["key"],
|
|
2221
|
+
branch_currency, now)
|
|
2222
|
+
if branch_currency and ("currency", branch_currency.get("key")) in decision_coverage:
|
|
2223
|
+
branch_currency["attention"] = None
|
|
2224
|
+
|
|
2225
|
+
actionable = {"ci": actionable_ci, "threads": actionable_threads, "comments": actionable_feedback}
|
|
2226
|
+
# Reset on any change to the candidate id-set so a burst arriving over several polls settles
|
|
2227
|
+
# into one wake; clear when it empties so the next candidate starts a fresh window.
|
|
2228
|
+
feedback_ids = sorted(str(f.get("id")) for f in actionable_feedback)
|
|
2229
|
+
if not feedback_ids:
|
|
2230
|
+
state["feedback_candidate_changed_at"] = None
|
|
2231
|
+
elif feedback_ids != state.get("feedback_candidate_ids"):
|
|
2232
|
+
state["feedback_candidate_changed_at"] = _iso(now)
|
|
2233
|
+
state["feedback_candidate_ids"] = feedback_ids
|
|
2234
|
+
if advance_trajectory:
|
|
2235
|
+
state["tick"] = state.get("tick", 0) + 1
|
|
2236
|
+
# Record CI recurrence transitions on EVERY observation (polls too) so a fail->clear->fail seen
|
|
2237
|
+
# only between agent ticks is not lost. head_sha for the check-level last_head is the observed
|
|
2238
|
+
# head; the trajectory-level last_head (for heads_since_progress) is agent-tick-only, inside
|
|
2239
|
+
# _update_trajectory.
|
|
2240
|
+
_record_check_history(state, head, new_checks)
|
|
2241
|
+
if advance_trajectory:
|
|
2242
|
+
trajectory = _update_trajectory(
|
|
2243
|
+
state, head, new_checks, new_threads, new_feedback, actionable, decision_coverage)
|
|
2244
|
+
else:
|
|
2245
|
+
# A watch poll detects change and advances the settle clock, but must NOT roll the rest of the
|
|
2246
|
+
# trajectory (tick counter, seen_threads, unresolved_series, heads_since_progress). Advancing
|
|
2247
|
+
# it would consume new_threads_this_tick — the waking poll marks the just-arrived thread
|
|
2248
|
+
# "seen", so the agent's real tick reads 0 new arrivals and the review-bot-treadmill
|
|
2249
|
+
# non-convergence trigger never fires. Only the agent's tick (advance_trajectory=True) rolls it.
|
|
2250
|
+
trajectory = {}
|
|
2251
|
+
|
|
2252
|
+
# --- Settle window: any observable movement resets the quiet clock. ---
|
|
2253
|
+
changed_this_tick = head_changed or _change_sig(state) != prior_change_sig or state.get("last_change_at") is None
|
|
2254
|
+
if changed_this_tick:
|
|
2255
|
+
state["last_change_at"] = _iso(now)
|
|
2256
|
+
quiet_seconds = _elapsed(state.get("last_change_at"), now)
|
|
2257
|
+
|
|
2258
|
+
# Workflow runs awaiting maintainer approval (fork-PR gate) create no check-run, so they are
|
|
2259
|
+
# invisible to the rollup above — surface them, and never call CI "ok" while the real CI is
|
|
2260
|
+
# gated. blocked_external = the loop cannot progress (no failing check to fix, but CI can't run)
|
|
2261
|
+
# and no one in this loop can unblock it — it is up to a maintainer of the base repo.
|
|
2262
|
+
awaiting_approval = state["awaiting_approval"]
|
|
2263
|
+
blocked_external_review_quiet_seconds = (
|
|
2264
|
+
_elapsed(state.get("blocked_external_review_last_activity_at"), now)
|
|
2265
|
+
if awaiting_approval > 0 else 0
|
|
2266
|
+
)
|
|
2267
|
+
# "OK" requires every check terminal, none failing, AND none gated on approval. A still-
|
|
2268
|
+
# IN_PROGRESS or awaiting-approval check is neither ok nor failing — do not exit green.
|
|
2269
|
+
all_checks_ok = checks_terminal and not has_failing and bool(cur["checks"]) and awaiting_approval == 0
|
|
2270
|
+
blocked_external = (awaiting_approval > 0 and not has_failing and checks_terminal
|
|
2271
|
+
and not actionable_threads and not actionable_feedback)
|
|
2272
|
+
invocation_elapsed = _active_elapsed(state, now)
|
|
2273
|
+
|
|
2274
|
+
return {
|
|
2275
|
+
"pr_state": cur["pr_state"],
|
|
2276
|
+
"pr_is_draft": cur.get("pr_is_draft"),
|
|
2277
|
+
"mergeable": cur["mergeable"],
|
|
2278
|
+
"merge_state_status": cur["merge_state_status"],
|
|
2279
|
+
"review_decision": cur["review_decision"],
|
|
2280
|
+
"head_sha": head,
|
|
2281
|
+
"head_changed": head_changed,
|
|
2282
|
+
"base": state.get("base"),
|
|
2283
|
+
"mergeability_certain": _mergeability_certain(
|
|
2284
|
+
cur.get("mergeable"), cur.get("merge_state_status"), cur.get("base")),
|
|
2285
|
+
"base_ref_blocker": _base_ref_blocker(cur.get("base")),
|
|
2286
|
+
"host_branch_update_capability": cur.get("host_branch_update_capability", "unknown"),
|
|
2287
|
+
"branch_currency": branch_currency,
|
|
2288
|
+
"unrequested_base_merge": unrequested_base_merge,
|
|
2289
|
+
"unrequested_base_merge_pending": unrequested_base_merge_pending,
|
|
2290
|
+
"branch_currency_blocker": ({
|
|
2291
|
+
"key": branch_currency.get("key"),
|
|
2292
|
+
"disposition": branch_currency.get("disposition"),
|
|
2293
|
+
"recovery_state": branch_currency.get("recovery_state"),
|
|
2294
|
+
} if branch_currency else None),
|
|
2295
|
+
"url": cur["url"],
|
|
2296
|
+
"has_failing_checks": has_failing,
|
|
2297
|
+
"checks_terminal": checks_terminal,
|
|
2298
|
+
"checks_present": bool(cur["checks"]),
|
|
2299
|
+
"all_checks_ok": all_checks_ok,
|
|
2300
|
+
"checks_awaiting_approval": awaiting_approval,
|
|
2301
|
+
"blocked_external": blocked_external,
|
|
2302
|
+
"blocked_external_first_seen_at": state.get("blocked_external_first_seen_at"),
|
|
2303
|
+
"feedback_candidate_changed_at": state.get("feedback_candidate_changed_at"),
|
|
2304
|
+
"blocked_external_review_last_activity_at": state.get(
|
|
2305
|
+
"blocked_external_review_last_activity_at"),
|
|
2306
|
+
"blocked_external_review_quiet_seconds": blocked_external_review_quiet_seconds,
|
|
2307
|
+
"blocked_external_review_moved_this_tick": (
|
|
2308
|
+
awaiting_approval > 0 and external_review_moved),
|
|
2309
|
+
"pr_chain": state["pr_chain"],
|
|
2310
|
+
"stack_blocker": _stack_blocker(state["pr_chain"], cur),
|
|
2311
|
+
"open_needs_human": open_needs_human,
|
|
2312
|
+
"needs_human_ids": needs_human_ids,
|
|
2313
|
+
"needs_human_residuals": needs_human_residuals,
|
|
2314
|
+
"human_decisions": [
|
|
2315
|
+
{"decision_id": decision.get("id"), "residual": decision.get("residual")}
|
|
2316
|
+
for decision in state.get("human_decisions", []) if isinstance(decision, dict)
|
|
2317
|
+
],
|
|
2318
|
+
"answered_human_decisions": state.get("answered_human_decisions", []),
|
|
2319
|
+
"actionable": {"threads": actionable_threads, "ci": actionable_ci, "comments": actionable_feedback},
|
|
2320
|
+
"counts": {"threads": len(actionable_threads), "ci": len(actionable_ci),
|
|
2321
|
+
"comments": len(actionable_feedback), "needs_human": open_needs_human},
|
|
2322
|
+
"changed_this_tick": changed_this_tick,
|
|
2323
|
+
"quiet_seconds": quiet_seconds,
|
|
2324
|
+
"invocation_id": state.get("invocation_id"),
|
|
2325
|
+
"invocation_started_at": state.get("started_at"),
|
|
2326
|
+
"invocation_elapsed_seconds": invocation_elapsed,
|
|
2327
|
+
"invocation_budget_seconds": state.get("invocation_budget_seconds"),
|
|
2328
|
+
"invocation_remaining_seconds": max(
|
|
2329
|
+
0,
|
|
2330
|
+
(state.get("invocation_budget_seconds") or 0) - invocation_elapsed,
|
|
2331
|
+
),
|
|
2332
|
+
"invocation_wall_elapsed_seconds": _elapsed(state.get("started_at"), now),
|
|
2333
|
+
"invocation_dead_time_seconds": int(state.get("dead_time_seconds") or 0),
|
|
2334
|
+
"invocation_backstop_seconds": state.get("invocation_backstop_seconds"),
|
|
2335
|
+
"persisted_state_created_at": state.get("state_created_at"),
|
|
2336
|
+
"persisted_state_age_seconds": _elapsed(state.get("state_created_at"), now),
|
|
2337
|
+
# Compatibility aliases for callers migrating from the original single-clock contract.
|
|
2338
|
+
"session_started_at": state.get("started_at"),
|
|
2339
|
+
"session_seconds": invocation_elapsed,
|
|
2340
|
+
"watch_generation": state.get("watch_generation"),
|
|
2341
|
+
"tick": state["tick"],
|
|
2342
|
+
"trajectory": trajectory,
|
|
2343
|
+
}, state
|
|
2344
|
+
|
|
2345
|
+
|
|
2346
|
+
def _settle_base(base):
|
|
2347
|
+
"""Base fields whose movement should reset the settle clock.
|
|
2348
|
+
|
|
2349
|
+
`stale-computation` is a presentation of a still-stable `race` candidate
|
|
2350
|
+
(same head + live base) plus a report flag. Hashing those presentation
|
|
2351
|
+
fields would reset last_change_at and add a synthetic settle delay.
|
|
2352
|
+
Genuine identity values and freshness stay in the signature.
|
|
2353
|
+
"""
|
|
2354
|
+
if not isinstance(base, dict):
|
|
2355
|
+
return base
|
|
2356
|
+
payload = {k: v for k, v in base.items() if k != "merge_computation_stale"}
|
|
2357
|
+
if payload.get("identity") == BASE_REF_STALE_COMPUTATION:
|
|
2358
|
+
payload["identity"] = BASE_REF_RACE
|
|
2359
|
+
return payload
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def _change_sig(state):
|
|
2363
|
+
"""Everything whose movement should reset the settle clock."""
|
|
2364
|
+
checks = {k: (v.get("status"), v.get("conclusion")) for k, v in state.get("checks", {}).items()}
|
|
2365
|
+
threads = {tid: _thread_identity(v) for tid, v in state.get("threads", {}).items()}
|
|
2366
|
+
# edit_id is part of the signal so a body edit of a silenced item still resets the settle
|
|
2367
|
+
# clock (review activity happened) even though it no longer reopens the item (#1309).
|
|
2368
|
+
feedback = {fid: (v.get("disposition"), v.get("edit_id"))
|
|
2369
|
+
for fid, v in state.get("feedback", {}).items()}
|
|
2370
|
+
human_decisions = sorted(
|
|
2371
|
+
item.get("id") for item in state.get("human_decisions", [])
|
|
2372
|
+
if isinstance(item, dict))
|
|
2373
|
+
currency = _load_branch_currency_state(state)
|
|
2374
|
+
current_currency = currency.get("items", {}).get(currency.get("current_key")) or {}
|
|
2375
|
+
return (checks, threads, feedback, human_decisions,
|
|
2376
|
+
state.get("review_decision"), state.get("mergeable"),
|
|
2377
|
+
state.get("merge_state_status"),
|
|
2378
|
+
json.dumps(state.get("pr_chain") or _default_pr_chain(), sort_keys=True),
|
|
2379
|
+
json.dumps(_settle_base(state.get("base")), sort_keys=True),
|
|
2380
|
+
(current_currency.get("host_branch_update_capability")
|
|
2381
|
+
if current_currency.get("status") == "BEHIND" else None),
|
|
2382
|
+
currency.get("current_key"), current_currency.get("disposition"),
|
|
2383
|
+
current_currency.get("attention"), current_currency.get("recovery_state"),
|
|
2384
|
+
current_currency.get("retry_count"), current_currency.get("mutation_consumed"),
|
|
2385
|
+
# awaiting-approval clearing is movement: a fork gate lifting must reset the settle clock
|
|
2386
|
+
# so merge-ready waits for the now-imminent check-runs instead of firing on an empty rollup.
|
|
2387
|
+
bool(state.get("awaiting_approval")))
|
|
2388
|
+
|
|
2389
|
+
|
|
2390
|
+
def _stack_blocker(chain, cur=None):
|
|
2391
|
+
"""Return the manager-currency residual that blocks target readiness, if any.
|
|
2392
|
+
Manager freshness (`needsRebase`) fires on plain trunk drift, which GitHub already prices into
|
|
2393
|
+
the target's own mergeability against its parent base; so a certain MERGEABLE/CLEAN read defers
|
|
2394
|
+
to GitHub and only a stale-or-unknown target that GitHub does not clear blocks."""
|
|
2395
|
+
status = (chain or {}).get("manager_status")
|
|
2396
|
+
if status == MANAGER_PROBE_ERROR:
|
|
2397
|
+
return "manager-probe-error"
|
|
2398
|
+
if (chain or {}).get("relationship_status") == RELATIONSHIP_PROBE_ERROR:
|
|
2399
|
+
return "relationship-probe-error"
|
|
2400
|
+
if status == MANAGER_CONFIRMED:
|
|
2401
|
+
freshness = chain.get("target_needs_rebase")
|
|
2402
|
+
if freshness is False:
|
|
2403
|
+
return None
|
|
2404
|
+
cur = cur or {}
|
|
2405
|
+
if (cur.get("mergeable") == "MERGEABLE" and cur.get("merge_state_status") == "CLEAN"
|
|
2406
|
+
and _mergeability_certain(cur.get("mergeable"), cur.get("merge_state_status"),
|
|
2407
|
+
cur.get("base"))):
|
|
2408
|
+
return None
|
|
2409
|
+
if freshness is True:
|
|
2410
|
+
return "target-needs-rebase"
|
|
2411
|
+
return "managed-freshness-unknown"
|
|
2412
|
+
return None
|
|
2413
|
+
|
|
2414
|
+
|
|
2415
|
+
def _parse_iso8601(value):
|
|
2416
|
+
if isinstance(value, str) and value.endswith("Z"):
|
|
2417
|
+
value = value[:-1] + "+00:00"
|
|
2418
|
+
return datetime.fromisoformat(value)
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
def _elapsed(iso_str, now):
|
|
2422
|
+
try:
|
|
2423
|
+
return int((now - _parse_iso8601(iso_str)).total_seconds())
|
|
2424
|
+
except (ValueError, TypeError):
|
|
2425
|
+
return 0
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
def _advance_activity(state, now, threshold=DEAD_TIME_THRESHOLD_SECONDS, accumulate=True):
|
|
2429
|
+
"""Advance the activity heartbeat, and (for the in-session watch) accumulate suspended time.
|
|
2430
|
+
|
|
2431
|
+
Every watch poll and every agent snapshot/mark marks activity. A gap wider than `threshold`
|
|
2432
|
+
means the whole process was not running — a suspended machine — so, when accumulating, the
|
|
2433
|
+
excess beyond the threshold is charged to dead time and later excluded from active elapsed.
|
|
2434
|
+
Agent snapshots/marks bump the heartbeat with `accumulate=False`, so active agent work keeps the
|
|
2435
|
+
heartbeat fresh and is not refunded as long as the agent touches state (snapshots or marks) within
|
|
2436
|
+
the threshold; a single silent tick longer than the threshold is the coarse-discriminator limit
|
|
2437
|
+
the plan defers, bounded by the wall-clock backstop. Dead time only ever grows in the watch loop.
|
|
2438
|
+
Clock-backward safe: `_elapsed` floors negative deltas at 0 and the accumulator never decreases.
|
|
2439
|
+
"""
|
|
2440
|
+
last = state.get("last_activity_at") or state.get("started_at")
|
|
2441
|
+
if accumulate:
|
|
2442
|
+
gap = _elapsed(last, now)
|
|
2443
|
+
if gap > threshold:
|
|
2444
|
+
state["dead_time_seconds"] = (state.get("dead_time_seconds") or 0) + (gap - threshold)
|
|
2445
|
+
state["last_activity_at"] = _iso(now)
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
def _active_elapsed(state, now):
|
|
2449
|
+
"""Wall-clock elapsed since the invocation anchor, minus accumulated suspended (dead) time."""
|
|
2450
|
+
wall = _elapsed(state.get("started_at"), now)
|
|
2451
|
+
return max(0, wall - int(state.get("dead_time_seconds") or 0))
|
|
2452
|
+
|
|
2453
|
+
|
|
2454
|
+
def _session_started_at(value):
|
|
2455
|
+
"""Parse one invocation-wide, timezone-aware budget anchor for reuse across state dirs."""
|
|
2456
|
+
try:
|
|
2457
|
+
parsed = _parse_iso8601(value)
|
|
2458
|
+
except (ValueError, TypeError):
|
|
2459
|
+
raise argparse.ArgumentTypeError("must be an ISO-8601 timestamp")
|
|
2460
|
+
if parsed.tzinfo is None:
|
|
2461
|
+
raise argparse.ArgumentTypeError("must include a timezone")
|
|
2462
|
+
return _iso(parsed.astimezone(timezone.utc))
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
@contextmanager
|
|
2466
|
+
def locked_state(state_dir, pr, repo, now):
|
|
2467
|
+
state_path = os.path.join(state_dir, "state.json")
|
|
2468
|
+
with _state_lock(state_dir):
|
|
2469
|
+
if os.path.exists(state_path):
|
|
2470
|
+
with open(state_path) as f:
|
|
2471
|
+
state = json.load(f)
|
|
2472
|
+
else:
|
|
2473
|
+
state = _empty_state(pr, repo, None, now)
|
|
2474
|
+
# Older state used `started_at` for both durable-state age and the active budget. Preserve
|
|
2475
|
+
# that original value as the state birth time before a new invocation replaces the clock.
|
|
2476
|
+
state.setdefault("state_created_at", state.get("started_at") or _iso(now))
|
|
2477
|
+
# Active-time accounting fields migrate onto legacy state on first observation. Seed the
|
|
2478
|
+
# heartbeat to *now*, not the old anchor: there is no recorded activity for the pre-migration
|
|
2479
|
+
# period, so charging that whole span as one supra-threshold gap would refund the entire
|
|
2480
|
+
# historical invocation (a 9h-old 8h run would read as ~15 min active and never max-runtime).
|
|
2481
|
+
# Seeding to load time makes the first poll see a ~0 gap and keeps elapsed on real wall-clock.
|
|
2482
|
+
state.setdefault("last_activity_at", _iso(now))
|
|
2483
|
+
state.setdefault("dead_time_seconds", 0.0)
|
|
2484
|
+
state.setdefault("invocation_backstop_seconds", DEFAULT_INVOCATION_BACKSTOP_SECONDS)
|
|
2485
|
+
box = {"state": state}
|
|
2486
|
+
yield box
|
|
2487
|
+
tmp = tempfile.NamedTemporaryFile("w", dir=state_dir, delete=False)
|
|
2488
|
+
json.dump(box["state"], tmp, indent=2)
|
|
2489
|
+
tmp.flush()
|
|
2490
|
+
os.fsync(tmp.fileno())
|
|
2491
|
+
tmp.close()
|
|
2492
|
+
_replace_atomic(tmp.name, state_path)
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
def _fetch_snapshot(args):
|
|
2496
|
+
"""Fetch current PR state without mutating the persisted babysit state."""
|
|
2497
|
+
if args.fetch_file:
|
|
2498
|
+
with open(args.fetch_file) as f:
|
|
2499
|
+
return json.load(f)
|
|
2500
|
+
return fetch(args.pr, args.repo)
|
|
2501
|
+
|
|
2502
|
+
|
|
2503
|
+
def _apply_invocation(box, args, now):
|
|
2504
|
+
"""Start explicitly, or reuse the one fixed clock named by an explicit invocation token."""
|
|
2505
|
+
state = box["state"]
|
|
2506
|
+
start_requested = bool(getattr(args, "start_invocation", False)
|
|
2507
|
+
or getattr(args, "reset_session", False))
|
|
2508
|
+
continue_requested = bool(getattr(args, "continue_invocation", False))
|
|
2509
|
+
invocation_id = getattr(args, "invocation_id", None)
|
|
2510
|
+
requested_started_at = getattr(args, "session_started_at", None)
|
|
2511
|
+
requested_budget = getattr(args, "invocation_budget_seconds", None)
|
|
2512
|
+
|
|
2513
|
+
if start_requested:
|
|
2514
|
+
invocation_id = uuid.uuid4().hex
|
|
2515
|
+
requested_started_at = _iso(now)
|
|
2516
|
+
requested_budget = requested_budget or DEFAULT_INVOCATION_BUDGET_SECONDS
|
|
2517
|
+
state["invocation_id"] = invocation_id
|
|
2518
|
+
state["started_at"] = requested_started_at
|
|
2519
|
+
state["invocation_budget_seconds"] = requested_budget
|
|
2520
|
+
# A fresh invocation resets the active-time clock: heartbeat at now, no dead time yet.
|
|
2521
|
+
state["last_activity_at"] = _iso(now)
|
|
2522
|
+
state["dead_time_seconds"] = 0.0
|
|
2523
|
+
state.setdefault("invocation_backstop_seconds", DEFAULT_INVOCATION_BACKSTOP_SECONDS)
|
|
2524
|
+
args.invocation_id = invocation_id
|
|
2525
|
+
args.session_started_at = requested_started_at
|
|
2526
|
+
args.invocation_budget_seconds = requested_budget
|
|
2527
|
+
args._started_new_invocation = True
|
|
2528
|
+
return
|
|
2529
|
+
|
|
2530
|
+
if not invocation_id:
|
|
2531
|
+
raise SystemExit("pass --start-invocation on the first snapshot or --invocation-id to resume it")
|
|
2532
|
+
|
|
2533
|
+
if continue_requested:
|
|
2534
|
+
if not requested_started_at or not requested_budget:
|
|
2535
|
+
raise SystemExit("--continue-invocation requires its fixed start and budget")
|
|
2536
|
+
if state.get("invocation_id") == invocation_id:
|
|
2537
|
+
try:
|
|
2538
|
+
same_anchor = (_parse_iso8601(state.get("started_at"))
|
|
2539
|
+
== _parse_iso8601(requested_started_at))
|
|
2540
|
+
except (ValueError, TypeError):
|
|
2541
|
+
same_anchor = False
|
|
2542
|
+
if not same_anchor or state.get("invocation_budget_seconds") != requested_budget:
|
|
2543
|
+
raise SystemExit("continuation cannot renew or extend an existing invocation")
|
|
2544
|
+
# Honor a carry arg on a same-invocation re-continue too, as a monotonic floor: raise the
|
|
2545
|
+
# dead-time to the carried value without clobbering time this dir already accumulated
|
|
2546
|
+
# (dead time only ever grows). This makes the carry order-independent — it lands whether
|
|
2547
|
+
# it arrives on the first adopt or a later re-continue.
|
|
2548
|
+
continue_dead_time = getattr(args, "continue_dead_time_seconds", None)
|
|
2549
|
+
if continue_dead_time is not None:
|
|
2550
|
+
state["dead_time_seconds"] = max(
|
|
2551
|
+
state.get("dead_time_seconds") or 0, float(continue_dead_time))
|
|
2552
|
+
return
|
|
2553
|
+
state["invocation_id"] = invocation_id
|
|
2554
|
+
state["started_at"] = requested_started_at
|
|
2555
|
+
state["invocation_budget_seconds"] = requested_budget
|
|
2556
|
+
# Adopting an invocation into this state dir establishes a fresh active-time clock. Seed the
|
|
2557
|
+
# heartbeat to now (not the shared anchor, which may be hours old) so the first poll sees a
|
|
2558
|
+
# ~0 gap and does not refund the span between the anchor and adoption as dead time.
|
|
2559
|
+
state["last_activity_at"] = _iso(now)
|
|
2560
|
+
# Dead time is per-state-dir, but a managed-stack continuation shares one active-time budget
|
|
2561
|
+
# across layers, so it explicitly carries the prior layer's accumulated dead time here. Absent
|
|
2562
|
+
# that arg (an ordinary adopt of an unrelated dir), reset to 0 rather than inherit stale state.
|
|
2563
|
+
continue_dead_time = getattr(args, "continue_dead_time_seconds", None)
|
|
2564
|
+
state["dead_time_seconds"] = float(continue_dead_time) if continue_dead_time is not None else 0.0
|
|
2565
|
+
state.setdefault("invocation_backstop_seconds", DEFAULT_INVOCATION_BACKSTOP_SECONDS)
|
|
2566
|
+
return
|
|
2567
|
+
|
|
2568
|
+
persisted_id = state.get("invocation_id")
|
|
2569
|
+
persisted_started_at = state.get("started_at")
|
|
2570
|
+
persisted_budget = state.get("invocation_budget_seconds")
|
|
2571
|
+
if persisted_id != invocation_id:
|
|
2572
|
+
raise SystemExit("invocation token does not match persisted state; start or continue explicitly")
|
|
2573
|
+
try:
|
|
2574
|
+
anchors_match = (_parse_iso8601(persisted_started_at)
|
|
2575
|
+
== _parse_iso8601(requested_started_at))
|
|
2576
|
+
except (ValueError, TypeError):
|
|
2577
|
+
anchors_match = False
|
|
2578
|
+
if not anchors_match:
|
|
2579
|
+
raise SystemExit("invocation token does not match the persisted budget anchor")
|
|
2580
|
+
if persisted_budget != requested_budget:
|
|
2581
|
+
raise SystemExit("invocation token does not match the persisted fixed budget")
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
def _apply_snapshot(box, args, cur, now, advance_trajectory):
|
|
2585
|
+
"""Apply one fetched snapshot to a caller-owned locked state box."""
|
|
2586
|
+
if box["state"].get("pr", {}).get("url") is None and cur.get("url"):
|
|
2587
|
+
box["state"]["pr"]["url"] = cur["url"]
|
|
2588
|
+
_apply_invocation(box, args, now)
|
|
2589
|
+
return diff(box["state"], cur, now, advance_trajectory=advance_trajectory)
|
|
2590
|
+
|
|
2591
|
+
|
|
2592
|
+
def _run_snapshot(args, now, advance_trajectory=True, watch_generation=None):
|
|
2593
|
+
"""One fetch -> diff -> persist. Returns the actionable/state dict."""
|
|
2594
|
+
cur = _fetch_snapshot(args)
|
|
2595
|
+
# Re-capture the clock *after* the (blocking) fetch so activity accounting reflects post-fetch
|
|
2596
|
+
# time: a suspend that lands during the fetch is then credited as dead time by this poll's
|
|
2597
|
+
# _advance_activity, instead of leaving the next top-of-loop budget check to fire max-runtime on
|
|
2598
|
+
# a gap it hasn't yet excluded. It also keeps a new invocation's slow first fetch off its budget.
|
|
2599
|
+
now = _now()
|
|
2600
|
+
with locked_state(args.state_dir, args.pr, args.repo, now) as box:
|
|
2601
|
+
if watch_generation is not None and box["state"].get("watch_generation") != watch_generation:
|
|
2602
|
+
raise _WatchSuperseded()
|
|
2603
|
+
if (watch_generation is not None
|
|
2604
|
+
and box["state"].get("invocation_id") != getattr(args, "invocation_id", None)):
|
|
2605
|
+
raise _InvocationSuperseded(box["state"].get("invocation_id"))
|
|
2606
|
+
# Only the self-sustaining in-session watch (watch_generation set) accumulates dead time;
|
|
2607
|
+
# an agent-driven snapshot bumps the heartbeat without accumulating, so checkpoint/durable
|
|
2608
|
+
# runs — which have no continuous poll cadence — keep pure wall-clock accounting.
|
|
2609
|
+
if box["state"].get("started_at"):
|
|
2610
|
+
_advance_activity(box["state"], now, accumulate=watch_generation is not None)
|
|
2611
|
+
actionable, box["state"] = _apply_snapshot(box, args, cur, now, advance_trajectory)
|
|
2612
|
+
return actionable
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
def cmd_snapshot(args):
|
|
2616
|
+
result = _run_snapshot(args, _now())
|
|
2617
|
+
if (getattr(args, "_started_new_invocation", False)
|
|
2618
|
+
and _elapsed(result.get("invocation_started_at"), _now()) > 60):
|
|
2619
|
+
raise SystemExit("new invocation unexpectedly inherited more than 60 seconds of elapsed time")
|
|
2620
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
2621
|
+
|
|
2622
|
+
|
|
2623
|
+
def _wake_reason(a, settle_seconds, feedback_coalesce_seconds=FEEDBACK_COALESCE_SECONDS):
|
|
2624
|
+
"""Why the in-session agent should wake and run a tick, or None to keep watching.
|
|
2625
|
+
Ordered by precedence — a terminal/blocked/needs-human state outranks a merge-ready read."""
|
|
2626
|
+
if a.get("pr_state") in ("MERGED", "CLOSED"):
|
|
2627
|
+
return "terminal"
|
|
2628
|
+
c = a.get("counts") or {}
|
|
2629
|
+
if c.get("threads", 0) or c.get("ci", 0):
|
|
2630
|
+
return "actionable"
|
|
2631
|
+
if c.get("comments", 0):
|
|
2632
|
+
# Coalesced per FEEDBACK_COALESCE_SECONDS above; falling through costs nothing, since the
|
|
2633
|
+
# set stays actionable and whichever tick runs still sees every candidate. `_elapsed` reads
|
|
2634
|
+
# a missing or unparseable clock as 0, so unknown must wake rather than hold forever.
|
|
2635
|
+
changed_at = a.get("feedback_candidate_changed_at")
|
|
2636
|
+
if changed_at is None or _elapsed(changed_at, _now()) >= feedback_coalesce_seconds:
|
|
2637
|
+
return "feedback-candidate"
|
|
2638
|
+
if a.get("stack_blocker"):
|
|
2639
|
+
return "stack-blocked"
|
|
2640
|
+
base_ref_blocker = (a.get("base_ref_blocker")
|
|
2641
|
+
if "base_ref_blocker" in a else _base_ref_blocker(a.get("base")))
|
|
2642
|
+
if base_ref_blocker:
|
|
2643
|
+
return "base-ref-blocked"
|
|
2644
|
+
if a.get("unrequested_base_merge"):
|
|
2645
|
+
return "unrequested-base-merge"
|
|
2646
|
+
branch_currency = a.get("branch_currency") or {}
|
|
2647
|
+
if a.get("has_failing_checks") and a.get("checks_terminal"):
|
|
2648
|
+
# a dispatched check left terminally red (counts.ci is 0 — nothing new to dispatch) is a
|
|
2649
|
+
# blocker to hand back, not a reason to idle to max-runtime.
|
|
2650
|
+
return "blocked-failing"
|
|
2651
|
+
# Branch maintenance is a third attention stream. It runs only after new review/CI work and
|
|
2652
|
+
# standing red blockers are clear, but a passive in-progress check or review signal does not
|
|
2653
|
+
# delay a BEHIND update that will invalidate that work anyway. Claimed observations wake only
|
|
2654
|
+
# for reconciliation; confirmed and parked residuals remain quiet.
|
|
2655
|
+
if branch_currency.get("attention") in (CURRENCY_ATTENTION_CLAIM,
|
|
2656
|
+
CURRENCY_ATTENTION_DECIDE,
|
|
2657
|
+
CURRENCY_ATTENTION_INSPECT,
|
|
2658
|
+
CURRENCY_ATTENTION_RECONCILE):
|
|
2659
|
+
return "branch-currency"
|
|
2660
|
+
if a.get("blocked_external"):
|
|
2661
|
+
return "blocked-external"
|
|
2662
|
+
if a.get("open_needs_human", 0):
|
|
2663
|
+
return "needs-human" # parked items block ready; surface them
|
|
2664
|
+
# A widened window is the agent's own judgment about evidence it looked at. When that evidence
|
|
2665
|
+
# moves, the judgment may no longer hold, so hand the decision back rather than sleeping out a
|
|
2666
|
+
# window armed against a state that has since changed. Movement is the whole trigger: unchanged
|
|
2667
|
+
# evidence never fires this, which is what keeps it from re-deciding something the agent already
|
|
2668
|
+
# settled. The engine reports that something moved and nothing more — whether the movement
|
|
2669
|
+
# resolved anything is the agent's call at the settle decision.
|
|
2670
|
+
if settle_seconds > DEFAULT_SETTLE_SECONDS and a.get("changed_this_tick"):
|
|
2671
|
+
return "review-evidence-moved"
|
|
2672
|
+
# merge-ready candidate: green, settled, nothing left in the attention set. Whether a review is
|
|
2673
|
+
# still on its way is the agent's judgment at the settle decision, from live GitHub evidence the
|
|
2674
|
+
# engine does not model; this wake hands it that decision rather than pre-empting it.
|
|
2675
|
+
# Interactive merge-ready does NOT require `all_checks_ok`'s "at least one observed check": a repo
|
|
2676
|
+
# with no configured checks has a CLEAN/MERGEABLE PR that should be callable ready. (That guard
|
|
2677
|
+
# stays in pipeline success, where a not-yet-created rollup must not read as a pass.)
|
|
2678
|
+
mergeability_certain = a.get("mergeability_certain")
|
|
2679
|
+
if mergeability_certain is None:
|
|
2680
|
+
mergeability_certain = _mergeability_certain(
|
|
2681
|
+
a.get("mergeable"), a.get("merge_state_status"), a.get("base"))
|
|
2682
|
+
if (mergeability_certain and a.get("mergeable") == "MERGEABLE"
|
|
2683
|
+
and a.get("merge_state_status") == "CLEAN"
|
|
2684
|
+
and a.get("checks_terminal") and not a.get("has_failing_checks")
|
|
2685
|
+
and a.get("checks_awaiting_approval", 0) == 0
|
|
2686
|
+
and a.get("branch_currency_blocker") is None
|
|
2687
|
+
and a.get("quiet_seconds", 0) >= settle_seconds):
|
|
2688
|
+
return "merge-ready"
|
|
2689
|
+
return None
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
def _emit_wake(reason, **fields):
|
|
2693
|
+
print(json.dumps({"event": "BABYSIT_WAKE", "reason": reason, **fields}), flush=True)
|
|
2694
|
+
|
|
2695
|
+
|
|
2696
|
+
def _persisted_watch_generation(args):
|
|
2697
|
+
"""Best-effort read of current ownership without creating or mutating watch state."""
|
|
2698
|
+
try:
|
|
2699
|
+
with open(os.path.join(args.state_dir, "state.json")) as f:
|
|
2700
|
+
state = json.load(f)
|
|
2701
|
+
except (OSError, json.JSONDecodeError):
|
|
2702
|
+
return None
|
|
2703
|
+
generation = state.get("watch_generation") if isinstance(state, dict) else None
|
|
2704
|
+
return generation if isinstance(generation, str) and generation else None
|
|
2705
|
+
|
|
2706
|
+
|
|
2707
|
+
def _blocker_sig(a):
|
|
2708
|
+
"""Identity of blockers the agent surfaces once but cannot self-clear — parked needs-human
|
|
2709
|
+
items, a dispatched terminally-red check, and a fork workflow awaiting maintainer approval. The
|
|
2710
|
+
watch captures this at arm time and does not re-wake on a blocker already in that baseline, so a
|
|
2711
|
+
parked residual keeps the watch alive (or the blocked-external bounded watch keeps polling for
|
|
2712
|
+
the gate to clear) instead of busy-waking or terminating on the same condition."""
|
|
2713
|
+
sig = set(a.get("needs_human_ids") or [])
|
|
2714
|
+
if a.get("has_failing_checks") and a.get("checks_terminal") and not (a.get("counts") or {}).get("ci"):
|
|
2715
|
+
sig.add("__terminal_red__")
|
|
2716
|
+
if a.get("blocked_external"):
|
|
2717
|
+
sig.add("__blocked_external__")
|
|
2718
|
+
sig.add(("approval-review-state", a.get("review_decision")))
|
|
2719
|
+
if a.get("stack_blocker"):
|
|
2720
|
+
sig.add(f"__stack__:{a['stack_blocker']}")
|
|
2721
|
+
if a.get("base_ref_blocker"):
|
|
2722
|
+
sig.add(f"__base_ref__:{a['base_ref_blocker']}")
|
|
2723
|
+
if a.get("unrequested_base_merge"):
|
|
2724
|
+
sig.add(("unrequested-base-merge", a["unrequested_base_merge"].get("head")))
|
|
2725
|
+
branch_currency = a.get("branch_currency") or {}
|
|
2726
|
+
currency_disposition = branch_currency.get("disposition")
|
|
2727
|
+
if (currency_disposition == CURRENCY_CONFIRMED
|
|
2728
|
+
or (currency_disposition == CURRENCY_CLAIMED
|
|
2729
|
+
and not branch_currency.get("attention"))):
|
|
2730
|
+
sig.add(("currency", branch_currency.get("key"), currency_disposition,
|
|
2731
|
+
branch_currency.get("recovery_state")))
|
|
2732
|
+
# A new head is "context materially changed": a human may have pushed a commit that answers or
|
|
2733
|
+
# supersedes a parked residual, so the head is part of the baseline — when it moves, the residual
|
|
2734
|
+
# is no longer "already-surfaced against this state" and the watch wakes to give the agent a tick
|
|
2735
|
+
# to reopen/reprocess it, instead of parking forever while it still blocks merge-ready.
|
|
2736
|
+
if sig:
|
|
2737
|
+
sig.add(("head", a.get("head_sha")))
|
|
2738
|
+
return frozenset(sig)
|
|
2739
|
+
|
|
2740
|
+
|
|
2741
|
+
def _process_identity(pid):
|
|
2742
|
+
"""Best-effort PID-reuse guard for replacing a prior watcher. If process identity cannot be
|
|
2743
|
+
proven, generation invalidation still suppresses its wake and we leave OS cleanup alone."""
|
|
2744
|
+
if IS_WINDOWS:
|
|
2745
|
+
return _win_process_identity(pid)
|
|
2746
|
+
try:
|
|
2747
|
+
r = subprocess.run(["ps", "-p", str(pid), "-o", "lstart=", "-o", "command="],
|
|
2748
|
+
capture_output=True, text=True, encoding="utf-8")
|
|
2749
|
+
except OSError:
|
|
2750
|
+
return None # no ps on PATH: unproven identity, same as a failed lookup
|
|
2751
|
+
if r.returncode != 0:
|
|
2752
|
+
return None
|
|
2753
|
+
return (r.stdout or "").strip() or None
|
|
2754
|
+
|
|
2755
|
+
|
|
2756
|
+
def _watch_candidate_path(args):
|
|
2757
|
+
return os.path.join(args.state_dir, "watch-candidate.json")
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def _read_watch_candidate(args):
|
|
2761
|
+
try:
|
|
2762
|
+
with open(_watch_candidate_path(args)) as f:
|
|
2763
|
+
candidate = json.load(f)
|
|
2764
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
2765
|
+
return {}
|
|
2766
|
+
return candidate if isinstance(candidate, dict) else {}
|
|
2767
|
+
|
|
2768
|
+
|
|
2769
|
+
def _reserve_watch_candidate(args, generation):
|
|
2770
|
+
"""Make this invocation the newest candidate without displacing the active watcher."""
|
|
2771
|
+
candidate = {
|
|
2772
|
+
"generation": generation,
|
|
2773
|
+
"pid": os.getpid(),
|
|
2774
|
+
"process_identity": _process_identity(os.getpid()),
|
|
2775
|
+
}
|
|
2776
|
+
with _state_lock(args.state_dir, exclusive=True):
|
|
2777
|
+
previous = _read_watch_candidate(args)
|
|
2778
|
+
tmp = tempfile.NamedTemporaryFile("w", dir=args.state_dir, delete=False)
|
|
2779
|
+
json.dump(candidate, tmp)
|
|
2780
|
+
tmp.flush()
|
|
2781
|
+
os.fsync(tmp.fileno())
|
|
2782
|
+
tmp.close()
|
|
2783
|
+
_replace_atomic(tmp.name, _watch_candidate_path(args))
|
|
2784
|
+
return previous
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def _clear_watch_candidate(args, generation):
|
|
2788
|
+
with _state_lock(args.state_dir, exclusive=True):
|
|
2789
|
+
if _read_watch_candidate(args).get("generation") != generation:
|
|
2790
|
+
return
|
|
2791
|
+
try:
|
|
2792
|
+
os.unlink(_watch_candidate_path(args))
|
|
2793
|
+
except FileNotFoundError:
|
|
2794
|
+
pass
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
def _activate_watch(args, generation, now, cur):
|
|
2798
|
+
"""Atomically activate this watcher and persist its successfully fetched preflight."""
|
|
2799
|
+
identity = _process_identity(os.getpid())
|
|
2800
|
+
with locked_state(args.state_dir, args.pr, args.repo, now) as box:
|
|
2801
|
+
if _read_watch_candidate(args).get("generation") != generation:
|
|
2802
|
+
return None, None
|
|
2803
|
+
state = box["state"]
|
|
2804
|
+
previous = {
|
|
2805
|
+
"pid": state.get("watch_pid"),
|
|
2806
|
+
"process_identity": state.get("watch_process_identity"),
|
|
2807
|
+
}
|
|
2808
|
+
state["watch_generation"] = generation
|
|
2809
|
+
state["watch_pid"] = os.getpid()
|
|
2810
|
+
state["watch_process_identity"] = identity
|
|
2811
|
+
# The arming poll is a real in-session watch poll: accumulate any suspended span since the
|
|
2812
|
+
# last activity (e.g. the watch armed right after the machine resumed) before reading elapsed.
|
|
2813
|
+
if state.get("started_at"):
|
|
2814
|
+
_advance_activity(state, now, accumulate=True)
|
|
2815
|
+
actionable, box["state"] = _apply_snapshot(box, args, cur, now, advance_trajectory=False)
|
|
2816
|
+
try:
|
|
2817
|
+
os.unlink(_watch_candidate_path(args))
|
|
2818
|
+
except FileNotFoundError:
|
|
2819
|
+
pass
|
|
2820
|
+
return previous, actionable
|
|
2821
|
+
|
|
2822
|
+
|
|
2823
|
+
def _watch_is_current(args, generation):
|
|
2824
|
+
"""Read the ownership generation under the state lock without rewriting state.json."""
|
|
2825
|
+
state_path = os.path.join(args.state_dir, "state.json")
|
|
2826
|
+
with _state_lock(args.state_dir, exclusive=False):
|
|
2827
|
+
if not os.path.exists(state_path):
|
|
2828
|
+
return False
|
|
2829
|
+
with open(state_path) as f:
|
|
2830
|
+
return json.load(f).get("watch_generation") == generation
|
|
2831
|
+
|
|
2832
|
+
|
|
2833
|
+
def _emit_wake_if_current(args, generation, reason, **fields):
|
|
2834
|
+
"""Serialize the final ownership check with takeover so an old generation cannot emit after
|
|
2835
|
+
the new generation has become current. Invocation replacement also supersedes every ordinary
|
|
2836
|
+
wake from the old budget, even when the replacement kept the same watch generation."""
|
|
2837
|
+
state_path = os.path.join(args.state_dir, "state.json")
|
|
2838
|
+
with _state_lock(args.state_dir, exclusive=False):
|
|
2839
|
+
if not os.path.exists(state_path):
|
|
2840
|
+
return False
|
|
2841
|
+
with open(state_path) as f:
|
|
2842
|
+
state = json.load(f)
|
|
2843
|
+
if state.get("watch_generation") != generation:
|
|
2844
|
+
return False
|
|
2845
|
+
current_invocation_id = state.get("invocation_id")
|
|
2846
|
+
watcher_invocation_id = getattr(args, "invocation_id", None)
|
|
2847
|
+
if current_invocation_id != watcher_invocation_id and reason != "invocation-superseded":
|
|
2848
|
+
_emit_wake(
|
|
2849
|
+
"invocation-superseded", watch_generation=generation,
|
|
2850
|
+
superseded_invocation_id=watcher_invocation_id,
|
|
2851
|
+
current_invocation_id=current_invocation_id,
|
|
2852
|
+
)
|
|
2853
|
+
return True
|
|
2854
|
+
_emit_wake(reason, watch_generation=generation, **fields)
|
|
2855
|
+
return True
|
|
2856
|
+
|
|
2857
|
+
|
|
2858
|
+
def _terminate_replaced_watch(previous):
|
|
2859
|
+
"""Promptly stop the replaced process, but never signal a PID whose identity no longer matches.
|
|
2860
|
+
|
|
2861
|
+
On Windows os.kill maps to TerminateProcess, so the replaced watcher dies abruptly instead of
|
|
2862
|
+
unwinding a SIGTERM handler: it cannot clear its own watch candidate, nor finish terminating a
|
|
2863
|
+
predecessor it was mid-handoff with. Neither leaks. The replacement has already written its own
|
|
2864
|
+
candidate before calling this, and any watcher left running reads a foreign watch_generation on
|
|
2865
|
+
its next poll and exits on its own — so the abrupt path costs at most one poll interval."""
|
|
2866
|
+
pid = previous.get("pid")
|
|
2867
|
+
identity = previous.get("process_identity")
|
|
2868
|
+
if not isinstance(pid, int) or pid == os.getpid() or not identity:
|
|
2869
|
+
return
|
|
2870
|
+
if _process_identity(pid) != identity:
|
|
2871
|
+
return
|
|
2872
|
+
try:
|
|
2873
|
+
os.kill(pid, signal.SIGTERM)
|
|
2874
|
+
except OSError:
|
|
2875
|
+
pass # exited between the identity check and here (Windows reports this as a plain OSError)
|
|
2876
|
+
|
|
2877
|
+
|
|
2878
|
+
def _downstack_targets(args):
|
|
2879
|
+
"""(pr, fetch_file_or_None) for each --downstack-pr; test fixtures bind a file per PR."""
|
|
2880
|
+
files = {}
|
|
2881
|
+
for spec in (getattr(args, "downstack_fetch_file", None) or []):
|
|
2882
|
+
pr_text, _, path = spec.partition("=")
|
|
2883
|
+
if pr_text.isdigit() and path:
|
|
2884
|
+
files[int(pr_text)] = path
|
|
2885
|
+
return [(pr, files.get(pr)) for pr in (getattr(args, "downstack_pr", None) or [])]
|
|
2886
|
+
|
|
2887
|
+
|
|
2888
|
+
def _downstack_sig(args):
|
|
2889
|
+
"""Stateless signature of the lower stack layers built from the same identities the engine
|
|
2890
|
+
uses to decide that a layer has work: the head, each unresolved thread's remote-truth identity
|
|
2891
|
+
(`_thread_identity` — a reply or an edit both change it), each non-thread comment by id (bodies
|
|
2892
|
+
are rewritten by status bots and are not new work), failing checks by the canonical `FAILING`
|
|
2893
|
+
set, the merge state when it demands maintenance, the manager classification tuple, and the PR
|
|
2894
|
+
state. The watcher captures it at arm time and wakes `downstack-actionable` only when it grows,
|
|
2895
|
+
so already-parked or dispatched lower-layer items do not re-wake, but any observation that would
|
|
2896
|
+
make a lower layer non-quiescent does."""
|
|
2897
|
+
sig = set()
|
|
2898
|
+
for pr, fetch_file in _downstack_targets(args):
|
|
2899
|
+
try:
|
|
2900
|
+
if fetch_file:
|
|
2901
|
+
with open(fetch_file) as f:
|
|
2902
|
+
cur = json.load(f)
|
|
2903
|
+
else:
|
|
2904
|
+
cur = fetch(pr, args.repo, light=True)
|
|
2905
|
+
except (OSError, ValueError, SystemExit):
|
|
2906
|
+
continue # a failed lower-layer probe never wakes or blocks; the next poll retries
|
|
2907
|
+
head = cur.get("head_sha")
|
|
2908
|
+
sig.add((pr, "head", head))
|
|
2909
|
+
sig.add((pr, "state", cur.get("pr_state")))
|
|
2910
|
+
for t in cur.get("threads") or []:
|
|
2911
|
+
sig.add((pr, "thread", t.get("thread_id"), _thread_identity(t)))
|
|
2912
|
+
for c in cur.get("feedback") or []:
|
|
2913
|
+
sig.add((pr, "comment", c.get("id")))
|
|
2914
|
+
for c in cur.get("checks") or []:
|
|
2915
|
+
if (c.get("status") or "").upper() == "COMPLETED" and (c.get("conclusion") or "").upper() in FAILING:
|
|
2916
|
+
sig.add((pr, "check", head, c.get("key")))
|
|
2917
|
+
status = (cur.get("merge_state_status") or "").upper()
|
|
2918
|
+
if status in ("DIRTY", "BEHIND"):
|
|
2919
|
+
sig.add((pr, "currency", status, head))
|
|
2920
|
+
chain = cur.get("pr_chain") if isinstance(cur.get("pr_chain"), dict) else {}
|
|
2921
|
+
sig.add((pr, "manager", chain.get("manager_status"), chain.get("target_needs_rebase")))
|
|
2922
|
+
return frozenset(sig)
|
|
2923
|
+
|
|
2924
|
+
|
|
2925
|
+
def cmd_watch(args):
|
|
2926
|
+
"""Deterministic background change-detector (no agent tokens between changes): poll on an
|
|
2927
|
+
interval, print one wake sentinel line and exit when there is something for the agent to do
|
|
2928
|
+
(work to inspect or a stop condition), or exit on the stop-signal file / max-runtime.
|
|
2929
|
+
A residual already present at arm time (a parked needs-human, or a dispatched terminal-red the
|
|
2930
|
+
agent already handed back) does NOT re-wake the loop — it keeps watching the other streams;
|
|
2931
|
+
only a *new* blocker (signature grown past the baseline) wakes."""
|
|
2932
|
+
stop_requested = threading.Event()
|
|
2933
|
+
interrupt_immediately = True
|
|
2934
|
+
superseded = False
|
|
2935
|
+
generation = uuid.uuid4().hex
|
|
2936
|
+
|
|
2937
|
+
# An already-requested stop is authoritative before this invocation becomes a candidate. Use
|
|
2938
|
+
# current ownership for the wake when one exists, but do not fetch, reserve, mutate watch state,
|
|
2939
|
+
# or disturb that incumbent merely to report the stop condition.
|
|
2940
|
+
if args.stop_file and os.path.exists(args.stop_file):
|
|
2941
|
+
_emit_wake("stop-signal", watch_generation=_persisted_watch_generation(args) or generation)
|
|
2942
|
+
return
|
|
2943
|
+
|
|
2944
|
+
def request_stop(_signum, _frame):
|
|
2945
|
+
nonlocal superseded
|
|
2946
|
+
superseded = True
|
|
2947
|
+
stop_requested.set()
|
|
2948
|
+
if interrupt_immediately:
|
|
2949
|
+
raise _WatchSuperseded()
|
|
2950
|
+
|
|
2951
|
+
# Cooperative supersession. Windows accepts this handler but never delivers SIGTERM across
|
|
2952
|
+
# processes, so there it is inert and the loop's own watch_generation checks are what retire a
|
|
2953
|
+
# replaced watcher — the handler is the fast path, not the correctness guarantee.
|
|
2954
|
+
prior_sigterm = signal.getsignal(signal.SIGTERM)
|
|
2955
|
+
signal.signal(signal.SIGTERM, request_stop)
|
|
2956
|
+
try:
|
|
2957
|
+
previous_candidate = _reserve_watch_candidate(args, generation)
|
|
2958
|
+
_terminate_replaced_watch(previous_candidate)
|
|
2959
|
+
|
|
2960
|
+
# Preflight before takeover: an invalid fetch/auth/config must not displace a healthy watcher.
|
|
2961
|
+
cur = _fetch_snapshot(args)
|
|
2962
|
+
if stop_requested.is_set():
|
|
2963
|
+
return
|
|
2964
|
+
|
|
2965
|
+
# Finish the handoff even if an even newer watcher arrives: otherwise that watcher could
|
|
2966
|
+
# stop us between activation and terminating our predecessor, orphaning the oldest process.
|
|
2967
|
+
interrupt_immediately = False
|
|
2968
|
+
previous, actionable = _activate_watch(args, generation, _now(), cur)
|
|
2969
|
+
if previous is None:
|
|
2970
|
+
superseded = True
|
|
2971
|
+
return
|
|
2972
|
+
_terminate_replaced_watch(previous)
|
|
2973
|
+
interrupt_immediately = True
|
|
2974
|
+
if stop_requested.is_set():
|
|
2975
|
+
return
|
|
2976
|
+
|
|
2977
|
+
armed = _blocker_sig(actionable) # blockers already surfaced when this generation armed
|
|
2978
|
+
downstack_targets = _downstack_targets(args)
|
|
2979
|
+
downstack_armed = _downstack_sig(args) if downstack_targets else frozenset()
|
|
2980
|
+
while True:
|
|
2981
|
+
if stop_requested.is_set():
|
|
2982
|
+
return
|
|
2983
|
+
if not _watch_is_current(args, generation):
|
|
2984
|
+
superseded = True
|
|
2985
|
+
return
|
|
2986
|
+
if args.stop_file and os.path.exists(args.stop_file):
|
|
2987
|
+
_emit_wake_if_current(args, generation, "stop-signal")
|
|
2988
|
+
return
|
|
2989
|
+
now = _now()
|
|
2990
|
+
# Active elapsed = raw wall-clock minus accumulated suspended time (dead_time was
|
|
2991
|
+
# updated by the previous poll's _run_snapshot, including any gap from a resumed
|
|
2992
|
+
# suspension). The 8h budget spends active time; the 3-day backstop is raw wall-clock.
|
|
2993
|
+
wall_elapsed = _elapsed(actionable.get("invocation_started_at"), now)
|
|
2994
|
+
dead_time = int(actionable.get("invocation_dead_time_seconds") or 0)
|
|
2995
|
+
invocation_elapsed = max(0, wall_elapsed - dead_time)
|
|
2996
|
+
budget = actionable.get("invocation_budget_seconds") or 0
|
|
2997
|
+
backstop = actionable.get("invocation_backstop_seconds") or 0
|
|
2998
|
+
actionable["invocation_elapsed_seconds"] = invocation_elapsed
|
|
2999
|
+
actionable["invocation_remaining_seconds"] = max(0, budget - invocation_elapsed)
|
|
3000
|
+
actionable["invocation_wall_elapsed_seconds"] = wall_elapsed
|
|
3001
|
+
actionable["persisted_state_age_seconds"] = _elapsed(
|
|
3002
|
+
actionable.get("persisted_state_created_at"), now)
|
|
3003
|
+
reason = _wake_reason(
|
|
3004
|
+
actionable, args.settle_seconds,
|
|
3005
|
+
getattr(args, "feedback_coalesce_seconds", FEEDBACK_COALESCE_SECONDS))
|
|
3006
|
+
if reason in ("terminal", "merge-ready"):
|
|
3007
|
+
_emit_wake_if_current(args, generation, reason, url=actionable.get("url"),
|
|
3008
|
+
pr_state=actionable.get("pr_state"), counts=actionable.get("counts"))
|
|
3009
|
+
return
|
|
3010
|
+
active_cap_hit = bool(budget) and invocation_elapsed >= budget
|
|
3011
|
+
backstop_hit = bool(backstop) and wall_elapsed >= backstop
|
|
3012
|
+
if active_cap_hit or backstop_hit:
|
|
3013
|
+
_emit_wake_if_current(
|
|
3014
|
+
args, generation, "max-runtime", url=actionable.get("url"),
|
|
3015
|
+
invocation_id=actionable.get("invocation_id"),
|
|
3016
|
+
invocation_started_at=actionable.get("invocation_started_at"),
|
|
3017
|
+
invocation_elapsed_seconds=actionable.get("invocation_elapsed_seconds"),
|
|
3018
|
+
invocation_budget_seconds=budget,
|
|
3019
|
+
invocation_wall_elapsed_seconds=wall_elapsed,
|
|
3020
|
+
invocation_backstop_seconds=backstop or None,
|
|
3021
|
+
max_runtime_ceiling=("backstop" if backstop_hit and not active_cap_hit
|
|
3022
|
+
else "active-budget"),
|
|
3023
|
+
persisted_state_age_seconds=actionable.get("persisted_state_age_seconds"),
|
|
3024
|
+
)
|
|
3025
|
+
return
|
|
3026
|
+
drain_seconds = getattr(args, "blocked_external_drain_seconds", None)
|
|
3027
|
+
drain_quiet = actionable.get("blocked_external_review_quiet_seconds", 0)
|
|
3028
|
+
if (drain_seconds is not None and actionable.get("blocked_external")
|
|
3029
|
+
and drain_quiet >= drain_seconds):
|
|
3030
|
+
_emit_wake_if_current(
|
|
3031
|
+
args, generation, "blocked-external-drained", url=actionable.get("url"),
|
|
3032
|
+
pr_state=actionable.get("pr_state"), counts=actionable.get("counts"),
|
|
3033
|
+
blocked_external_review_quiet_seconds=drain_quiet,
|
|
3034
|
+
blocked_external_drain_seconds=drain_seconds,
|
|
3035
|
+
)
|
|
3036
|
+
return
|
|
3037
|
+
approval_review_moved = (
|
|
3038
|
+
reason == "blocked-external"
|
|
3039
|
+
and actionable.get("blocked_external_review_moved_this_tick"))
|
|
3040
|
+
if (reason in ("needs-human", "blocked-failing", "blocked-external",
|
|
3041
|
+
"stack-blocked", "base-ref-blocked", "unrequested-base-merge")
|
|
3042
|
+
and not approval_review_moved and _blocker_sig(actionable) <= armed):
|
|
3043
|
+
reason = None # already-surfaced residual — keep watching, do not re-wake or terminate
|
|
3044
|
+
if reason:
|
|
3045
|
+
_emit_wake_if_current(args, generation, reason, url=actionable.get("url"),
|
|
3046
|
+
pr_state=actionable.get("pr_state"), counts=actionable.get("counts"))
|
|
3047
|
+
return
|
|
3048
|
+
if downstack_targets:
|
|
3049
|
+
grown = _downstack_sig(args) - downstack_armed
|
|
3050
|
+
if grown:
|
|
3051
|
+
_emit_wake_if_current(
|
|
3052
|
+
args, generation, "downstack-actionable", url=actionable.get("url"),
|
|
3053
|
+
pr_state=actionable.get("pr_state"), counts=actionable.get("counts"),
|
|
3054
|
+
downstack_prs=sorted({item[0] for item in grown}))
|
|
3055
|
+
return
|
|
3056
|
+
remaining = max(0, budget - invocation_elapsed) if budget else args.interval
|
|
3057
|
+
wait_seconds = min(args.interval, remaining)
|
|
3058
|
+
if stop_requested.wait(wait_seconds):
|
|
3059
|
+
return
|
|
3060
|
+
if not _watch_is_current(args, generation):
|
|
3061
|
+
superseded = True
|
|
3062
|
+
return
|
|
3063
|
+
actionable = _run_snapshot(args, _now(), advance_trajectory=False,
|
|
3064
|
+
watch_generation=generation)
|
|
3065
|
+
except _WatchSuperseded:
|
|
3066
|
+
superseded = True
|
|
3067
|
+
return
|
|
3068
|
+
except _InvocationSuperseded as exc:
|
|
3069
|
+
superseded = True
|
|
3070
|
+
_emit_wake_if_current(
|
|
3071
|
+
args, generation, "invocation-superseded",
|
|
3072
|
+
superseded_invocation_id=getattr(args, "invocation_id", None),
|
|
3073
|
+
current_invocation_id=exc.current_invocation_id,
|
|
3074
|
+
)
|
|
3075
|
+
return
|
|
3076
|
+
finally:
|
|
3077
|
+
interrupt_immediately = False
|
|
3078
|
+
_clear_watch_candidate(args, generation)
|
|
3079
|
+
# A newer owner can still signal our recorded PID after observing us stale but before this
|
|
3080
|
+
# process has exited. Keep that late takeover signal harmless; ordinary wake/timeout/stop
|
|
3081
|
+
# returns restore the embedding caller's handler as before.
|
|
3082
|
+
signal.signal(signal.SIGTERM, signal.SIG_IGN if superseded else prior_sigterm)
|
|
3083
|
+
|
|
3084
|
+
|
|
3085
|
+
def _mark_thread_records(item_ids, args, state):
|
|
3086
|
+
"""Best-effort current records for every thread covered by one atomic mark."""
|
|
3087
|
+
wanted = set(item_ids)
|
|
3088
|
+
if not wanted:
|
|
3089
|
+
return {}
|
|
3090
|
+
try:
|
|
3091
|
+
if getattr(args, "fetch_file", None):
|
|
3092
|
+
with open(args.fetch_file) as f:
|
|
3093
|
+
threads = json.load(f).get("threads", [])
|
|
3094
|
+
else:
|
|
3095
|
+
owner, name, host = _resolve_repo_ref(args.repo, (state.get("pr") or {}).get("url"))
|
|
3096
|
+
threads = fetch_threads(args.pr, owner, name, host)
|
|
3097
|
+
except (SystemExit, Exception): # SystemExit (from _run_checked) is not an Exception subclass
|
|
3098
|
+
return {}
|
|
3099
|
+
return {
|
|
3100
|
+
thread.get("thread_id"): thread
|
|
3101
|
+
for thread in threads if thread.get("thread_id") in wanted
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
|
|
3105
|
+
def _mark_thread_baselines(item_ids, args, state):
|
|
3106
|
+
"""Best-effort current identities for every thread covered by one atomic mark."""
|
|
3107
|
+
return {
|
|
3108
|
+
thread_id: [thread.get("last_comment_id"), thread.get("last_comment_at")]
|
|
3109
|
+
for thread_id, thread in _mark_thread_records(item_ids, args, state).items()
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
|
|
3113
|
+
def _mark_thread_baseline(item_id, args, state):
|
|
3114
|
+
"""Compatibility wrapper for an ordinary single-thread dispatched mark."""
|
|
3115
|
+
return _mark_thread_baselines([item_id], args, state).get(item_id)
|
|
3116
|
+
|
|
3117
|
+
|
|
3118
|
+
def _mark_branch_currency(args, state, now):
|
|
3119
|
+
currency = _load_branch_currency_state(state)
|
|
3120
|
+
key = args.currency_key
|
|
3121
|
+
if not key or currency.get("current_key") != key:
|
|
3122
|
+
raise SystemExit("currency mark requires the exact current observation key")
|
|
3123
|
+
item = currency.get("items", {}).get(key)
|
|
3124
|
+
if not isinstance(item, dict):
|
|
3125
|
+
raise SystemExit("currency mark requires a current observed item")
|
|
3126
|
+
prior = item.get("disposition", DISPOSITION_OPEN)
|
|
3127
|
+
outcome = args.currency_outcome
|
|
3128
|
+
inspected_fingerprint = args.currency_inspected_fingerprint
|
|
3129
|
+
|
|
3130
|
+
if outcome:
|
|
3131
|
+
if prior != CURRENCY_CLAIMED:
|
|
3132
|
+
raise SystemExit("currency outcomes require a claimed observation")
|
|
3133
|
+
item["reconciled_invocation_id"] = state.get("invocation_id")
|
|
3134
|
+
item["reconciled_at"] = _iso(now)
|
|
3135
|
+
if outcome == CURRENCY_OUTCOME_MUTATION_OBSERVED:
|
|
3136
|
+
item["mutation_consumed"] = True
|
|
3137
|
+
item["mutation_observed_at"] = _iso(now)
|
|
3138
|
+
item["recovery_state"] = CURRENCY_OUTCOME_MUTATION_OBSERVED
|
|
3139
|
+
elif outcome == CURRENCY_OUTCOME_AMBIGUOUS:
|
|
3140
|
+
item["recovery_state"] = CURRENCY_OUTCOME_AMBIGUOUS
|
|
3141
|
+
elif outcome == CURRENCY_OUTCOME_PROVEN_NO_MUTATION:
|
|
3142
|
+
if item.get("mutation_consumed"):
|
|
3143
|
+
raise SystemExit("cannot record no mutation after mutation start was observed")
|
|
3144
|
+
retries = int(item.get("retry_count", 0))
|
|
3145
|
+
if retries < 1:
|
|
3146
|
+
item["retry_count"] = retries + 1
|
|
3147
|
+
item["disposition"] = DISPOSITION_OPEN
|
|
3148
|
+
item["recovery_state"] = "retry-authorized"
|
|
3149
|
+
item["retry_not_before"] = _iso(
|
|
3150
|
+
now + timedelta(seconds=CURRENCY_RETRY_BACKOFF_SECONDS))
|
|
3151
|
+
item.pop("claimed_invocation_id", None)
|
|
3152
|
+
item.pop("reconciled_invocation_id", None)
|
|
3153
|
+
else:
|
|
3154
|
+
item["disposition"] = DISPOSITION_OPEN
|
|
3155
|
+
item["recovery_state"] = "retry-exhausted"
|
|
3156
|
+
state["last_action"] = f"{outcome} currency {key}"
|
|
3157
|
+
return item.get("recovery_state") in (
|
|
3158
|
+
CURRENCY_OUTCOME_AMBIGUOUS, "retry-exhausted")
|
|
3159
|
+
|
|
3160
|
+
if inspected_fingerprint:
|
|
3161
|
+
if prior != DISPOSITION_OPEN:
|
|
3162
|
+
raise SystemExit("currency inspection requires an open observation")
|
|
3163
|
+
parks = currency.get("semantic_parks") or {}
|
|
3164
|
+
if not parks:
|
|
3165
|
+
raise SystemExit("currency inspection requires carried semantic conflict evidence")
|
|
3166
|
+
item["inspected_semantic_conflict_fingerprint"] = inspected_fingerprint
|
|
3167
|
+
item["inspected_at"] = _iso(now)
|
|
3168
|
+
if inspected_fingerprint in parks:
|
|
3169
|
+
item["semantic_conflict_fingerprint"] = inspected_fingerprint
|
|
3170
|
+
item["recovery_state"] = "semantic-unchanged"
|
|
3171
|
+
item["inspection_result"] = "unchanged"
|
|
3172
|
+
else:
|
|
3173
|
+
item["inspection_required"] = False
|
|
3174
|
+
item["inspection_result"] = "changed"
|
|
3175
|
+
item["recovery_state"] = "semantic-changed"
|
|
3176
|
+
item.pop("semantic_conflict_fingerprint", None)
|
|
3177
|
+
# The preview describes the current conflict set for this head. Once it differs, the
|
|
3178
|
+
# carried fingerprints are no longer standing residuals and must not tax every later
|
|
3179
|
+
# base generation with inspection of stale evidence.
|
|
3180
|
+
currency["semantic_parks"] = {}
|
|
3181
|
+
item["parked_semantic_fingerprints"] = []
|
|
3182
|
+
state["last_action"] = f"inspected currency {key}"
|
|
3183
|
+
return inspected_fingerprint in parks
|
|
3184
|
+
|
|
3185
|
+
disposition = args.currency_disposition
|
|
3186
|
+
allowed = {
|
|
3187
|
+
DISPOSITION_OPEN: {DISPOSITION_OPEN, CURRENCY_CLAIMED},
|
|
3188
|
+
CURRENCY_CLAIMED: {CURRENCY_CLAIMED, CURRENCY_CONFIRMED},
|
|
3189
|
+
CURRENCY_CONFIRMED: {CURRENCY_CONFIRMED, DISPOSITION_OPEN},
|
|
3190
|
+
}
|
|
3191
|
+
if disposition not in allowed.get(prior, set()):
|
|
3192
|
+
raise SystemExit(f"invalid currency transition: {prior} -> {disposition}")
|
|
3193
|
+
if (prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED
|
|
3194
|
+
and item.get("inspection_required")):
|
|
3195
|
+
raise SystemExit("currency claim requires semantic-fingerprint inspection first")
|
|
3196
|
+
if (prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED
|
|
3197
|
+
and _needs_human_source_is_covered(state, key, "currency")):
|
|
3198
|
+
raise SystemExit("currency claim requires the current human decision to be answered")
|
|
3199
|
+
if (prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED
|
|
3200
|
+
and _currency_mutation_requires_answer(state, key, item)):
|
|
3201
|
+
raise SystemExit("currency claim requires an exact human answer")
|
|
3202
|
+
if prior == DISPOSITION_OPEN and disposition == CURRENCY_CLAIMED:
|
|
3203
|
+
retry_not_before = item.get("retry_not_before")
|
|
3204
|
+
if retry_not_before:
|
|
3205
|
+
try:
|
|
3206
|
+
if _parse_iso8601(retry_not_before) > now:
|
|
3207
|
+
raise SystemExit("currency retry backoff has not elapsed")
|
|
3208
|
+
except (ValueError, TypeError):
|
|
3209
|
+
raise SystemExit("currency retry backoff is invalid")
|
|
3210
|
+
budget = state.get("invocation_budget_seconds")
|
|
3211
|
+
backstop = state.get("invocation_backstop_seconds")
|
|
3212
|
+
if ((budget is not None and _active_elapsed(state, now) >= budget)
|
|
3213
|
+
or (backstop is not None and _elapsed(state.get("started_at"), now) >= backstop)):
|
|
3214
|
+
raise SystemExit("currency claim cannot start after max-runtime")
|
|
3215
|
+
item["disposition"] = disposition
|
|
3216
|
+
item["transitioned_at"] = _iso(now)
|
|
3217
|
+
if disposition == CURRENCY_CLAIMED:
|
|
3218
|
+
# A repeated claim is deliberately idempotent. It cannot transfer or renew an existing
|
|
3219
|
+
# claim; a later invocation must reconcile it and record an explicit outcome.
|
|
3220
|
+
if prior == DISPOSITION_OPEN:
|
|
3221
|
+
item["mutation_requires_answer"] = False
|
|
3222
|
+
item["claimed_invocation_id"] = state.get("invocation_id")
|
|
3223
|
+
item["claimed_at"] = _iso(now)
|
|
3224
|
+
item["attempt_number"] = int(item.get("retry_count", 0)) + 1
|
|
3225
|
+
item["mutation_consumed"] = False
|
|
3226
|
+
item["recovery_state"] = "claimed"
|
|
3227
|
+
item.pop("reconciled_invocation_id", None)
|
|
3228
|
+
elif disposition == CURRENCY_CONFIRMED:
|
|
3229
|
+
item["confirmed_invocation_id"] = state.get("invocation_id")
|
|
3230
|
+
elif disposition == DISPOSITION_OPEN:
|
|
3231
|
+
_reactivate_currency_item(currency, item)
|
|
3232
|
+
else:
|
|
3233
|
+
item["parked_semantic_fingerprints"] = sorted(currency.get("semantic_parks") or {})
|
|
3234
|
+
state["last_action"] = f"{disposition} currency {key}"
|
|
3235
|
+
return False
|
|
3236
|
+
|
|
3237
|
+
|
|
3238
|
+
def _load_needs_human_residual(path, item_id=None, expected_kind=None):
|
|
3239
|
+
if not path:
|
|
3240
|
+
raise SystemExit("needs-human marks require --residual-file")
|
|
3241
|
+
try:
|
|
3242
|
+
with open(path, encoding="utf-8") as f:
|
|
3243
|
+
residual = json.load(f)
|
|
3244
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
3245
|
+
raise SystemExit(f"cannot read --residual-file: {exc}")
|
|
3246
|
+
error = _needs_human_residual_error(residual)
|
|
3247
|
+
if error:
|
|
3248
|
+
raise SystemExit(f"invalid --residual-file: {error}")
|
|
3249
|
+
if item_id is not None and not any(
|
|
3250
|
+
source["id"] == item_id and source["kind"] == expected_kind
|
|
3251
|
+
for source in residual["sources"]):
|
|
3252
|
+
raise SystemExit("--residual-file sources must include the marked item ID and kind")
|
|
3253
|
+
return residual
|
|
3254
|
+
|
|
3255
|
+
|
|
3256
|
+
def _load_human_answer(path):
|
|
3257
|
+
if not path:
|
|
3258
|
+
raise SystemExit("--answer-decision requires --answer-file")
|
|
3259
|
+
try:
|
|
3260
|
+
with open(path, encoding="utf-8") as f:
|
|
3261
|
+
answer = f.read().strip()
|
|
3262
|
+
except OSError as exc:
|
|
3263
|
+
raise SystemExit(f"cannot read --answer-file: {exc}")
|
|
3264
|
+
if not answer:
|
|
3265
|
+
raise SystemExit("--answer-file must contain the human's answer")
|
|
3266
|
+
return answer
|
|
3267
|
+
|
|
3268
|
+
|
|
3269
|
+
def _park_human_decision(state, residual, args, now):
|
|
3270
|
+
"""Freeze one complete decision over ordinary source observations, then publish it."""
|
|
3271
|
+
threads = state.get("threads", {})
|
|
3272
|
+
feedback = state.get("feedback", {})
|
|
3273
|
+
checks = state.get("checks", {})
|
|
3274
|
+
decisions = state.setdefault("human_decisions", [])
|
|
3275
|
+
existing = next((
|
|
3276
|
+
decision for decision in decisions
|
|
3277
|
+
if isinstance(decision, dict)
|
|
3278
|
+
and _residual_identity(decision.get("residual")) == _residual_identity(residual)
|
|
3279
|
+
), None)
|
|
3280
|
+
if existing is not None:
|
|
3281
|
+
_install_human_decisions(state, decisions)
|
|
3282
|
+
return existing
|
|
3283
|
+
covered = {
|
|
3284
|
+
(source.get("kind"), source.get("id"))
|
|
3285
|
+
for decision in decisions if isinstance(decision, dict)
|
|
3286
|
+
for source in decision.get("sources") or [] if isinstance(source, dict)
|
|
3287
|
+
}
|
|
3288
|
+
overlap = covered.intersection(_source_identity(source) for source in residual["sources"])
|
|
3289
|
+
if overlap:
|
|
3290
|
+
raise SystemExit("needs-human source already belongs to another current decision")
|
|
3291
|
+
|
|
3292
|
+
for source in residual["sources"]:
|
|
3293
|
+
kind, source_id = _source_identity(source)
|
|
3294
|
+
if not _source_can_enter_human_decision(
|
|
3295
|
+
state, kind, source_id, threads, feedback, checks):
|
|
3296
|
+
raise SystemExit(f"needs-human source is not current decision evidence: {kind} {source_id}")
|
|
3297
|
+
if kind == "currency" and args.semantic_conflict_fingerprint:
|
|
3298
|
+
currency = _load_branch_currency_state(state)
|
|
3299
|
+
item = currency["items"][source_id]
|
|
3300
|
+
fingerprint = args.semantic_conflict_fingerprint
|
|
3301
|
+
item["semantic_conflict_fingerprint"] = fingerprint
|
|
3302
|
+
currency.setdefault("semantic_parks", {})[fingerprint] = {
|
|
3303
|
+
"head_sha": item.get("head_sha"),
|
|
3304
|
+
"status": item.get("status"),
|
|
3305
|
+
"route": item.get("route"),
|
|
3306
|
+
"observation_key": source_id,
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
thread_ids = [
|
|
3310
|
+
source["id"] for source in residual["sources"] if source["kind"] == "thread"]
|
|
3311
|
+
thread_records = _mark_thread_records(thread_ids, args, state)
|
|
3312
|
+
thread_baselines = {
|
|
3313
|
+
thread_id: [thread.get("last_comment_id"), thread.get("last_comment_at")]
|
|
3314
|
+
for thread_id, thread in thread_records.items()
|
|
3315
|
+
}
|
|
3316
|
+
missing_thread_baselines = sorted(set(thread_ids) - set(thread_baselines))
|
|
3317
|
+
if missing_thread_baselines:
|
|
3318
|
+
raise SystemExit(
|
|
3319
|
+
"cannot freeze the post-reply thread observation: "
|
|
3320
|
+
+ ", ".join(missing_thread_baselines))
|
|
3321
|
+
authoritative_thread_urls = {
|
|
3322
|
+
thread_id: thread.get("url") for thread_id, thread in thread_records.items()
|
|
3323
|
+
}
|
|
3324
|
+
if (any(not isinstance(url, str) or not url.strip()
|
|
3325
|
+
for url in authoritative_thread_urls.values())
|
|
3326
|
+
or set(residual["thread_urls"]) != set(authoritative_thread_urls.values())):
|
|
3327
|
+
raise SystemExit("thread_urls must match the authoritative URL of every thread source")
|
|
3328
|
+
sources = []
|
|
3329
|
+
for source in residual["sources"]:
|
|
3330
|
+
kind, source_id = _source_identity(source)
|
|
3331
|
+
if kind == "thread":
|
|
3332
|
+
observation = thread_baselines[source_id]
|
|
3333
|
+
elif kind in ("comment", "review"):
|
|
3334
|
+
observation = _current_source_observation(
|
|
3335
|
+
state, kind, source_id, threads, feedback, checks)
|
|
3336
|
+
if args.comment == source_id and args.acted_edit_id:
|
|
3337
|
+
observation = [args.acted_edit_id]
|
|
3338
|
+
else:
|
|
3339
|
+
observation = _current_source_observation(
|
|
3340
|
+
state, kind, source_id, threads, feedback, checks)
|
|
3341
|
+
if observation is None:
|
|
3342
|
+
raise SystemExit(f"cannot freeze needs-human observation: {kind} {source_id}")
|
|
3343
|
+
sources.append({"kind": kind, "id": source_id, "observation": observation})
|
|
3344
|
+
|
|
3345
|
+
if any(
|
|
3346
|
+
_answered_human_source_matches(
|
|
3347
|
+
state, source["kind"], source["id"], source["observation"])
|
|
3348
|
+
for source in sources):
|
|
3349
|
+
raise SystemExit("source observations already have a recorded human answer")
|
|
3350
|
+
_release_ordinary_suppression_for_decision(state, sources, threads, feedback)
|
|
3351
|
+
decision = _decision_record(residual, sources, _iso(now))
|
|
3352
|
+
if any(item.get("id") == decision["id"] for item in decisions if isinstance(item, dict)):
|
|
3353
|
+
return decision
|
|
3354
|
+
decisions.append(decision)
|
|
3355
|
+
_install_human_decisions(state, decisions)
|
|
3356
|
+
return decision
|
|
3357
|
+
|
|
3358
|
+
|
|
3359
|
+
def _invalidate_human_source(state, item_id, kind):
|
|
3360
|
+
kept = [
|
|
3361
|
+
decision for decision in state.get("human_decisions") or []
|
|
3362
|
+
if not any(source.get("id") == item_id and source.get("kind") == kind
|
|
3363
|
+
for source in decision.get("sources") or [] if isinstance(source, dict))
|
|
3364
|
+
]
|
|
3365
|
+
_replace_human_decisions(state, kept)
|
|
3366
|
+
|
|
3367
|
+
|
|
3368
|
+
def _needs_human_source_is_covered(state, item_id, kind):
|
|
3369
|
+
return any(
|
|
3370
|
+
source.get("id") == item_id and source.get("kind") == kind
|
|
3371
|
+
for decision in state.get("human_decisions") or []
|
|
3372
|
+
if isinstance(decision, dict)
|
|
3373
|
+
for source in decision.get("sources") or []
|
|
3374
|
+
if isinstance(source, dict)
|
|
3375
|
+
)
|
|
3376
|
+
|
|
3377
|
+
|
|
3378
|
+
def _answer_human_decision(state, decision_id, answer_file, now):
|
|
3379
|
+
answer = _load_human_answer(answer_file)
|
|
3380
|
+
current = next((
|
|
3381
|
+
decision for decision in state.get("human_decisions") or []
|
|
3382
|
+
if isinstance(decision, dict) and decision.get("id") == decision_id
|
|
3383
|
+
), None)
|
|
3384
|
+
if current is None:
|
|
3385
|
+
existing = next((
|
|
3386
|
+
decision for decision in state.get("answered_human_decisions") or []
|
|
3387
|
+
if isinstance(decision, dict) and decision.get("id") == decision_id
|
|
3388
|
+
), None)
|
|
3389
|
+
if existing and existing.get("answer") == answer:
|
|
3390
|
+
return existing
|
|
3391
|
+
raise SystemExit("--answer-decision must name an exact current decision ID")
|
|
3392
|
+
kept = [
|
|
3393
|
+
decision for decision in state.get("human_decisions") or []
|
|
3394
|
+
if not isinstance(decision, dict) or decision.get("id") != decision_id
|
|
3395
|
+
]
|
|
3396
|
+
_replace_human_decisions(state, kept)
|
|
3397
|
+
answered = {**json.loads(json.dumps(current)), "answer": answer, "answered_at": _iso(now)}
|
|
3398
|
+
state.setdefault("answered_human_decisions", []).append(answered)
|
|
3399
|
+
state["last_action"] = f"answered human decision {decision_id}"
|
|
3400
|
+
return answered
|
|
3401
|
+
|
|
3402
|
+
|
|
3403
|
+
def cmd_mark(args):
|
|
3404
|
+
now = _now()
|
|
3405
|
+
marked = None
|
|
3406
|
+
with locked_state(args.state_dir, args.pr, args.repo, now) as box:
|
|
3407
|
+
_apply_invocation(box, args, now)
|
|
3408
|
+
if (args.check or args.thread or args.comment) and args.residual_file \
|
|
3409
|
+
and args.disposition != DISPOSITION_NEEDS_HUMAN:
|
|
3410
|
+
raise SystemExit("--residual-file requires --disposition needs-human for source marks")
|
|
3411
|
+
if (args.disposition == DISPOSITION_NEEDS_HUMAN and not args.residual_file
|
|
3412
|
+
and not (args.check or args.thread or args.comment)):
|
|
3413
|
+
raise SystemExit("needs-human marks require --residual-file")
|
|
3414
|
+
state = box["state"]
|
|
3415
|
+
# An agent-driven mark is activity: bump the heartbeat (no accumulation) so a long tick
|
|
3416
|
+
# that only marks — never snapshots — still never has its active time refunded as dead time.
|
|
3417
|
+
if state.get("started_at"):
|
|
3418
|
+
_advance_activity(state, now, accumulate=False)
|
|
3419
|
+
if args.answer_decision or args.answer_file:
|
|
3420
|
+
if not args.answer_decision or not args.answer_file:
|
|
3421
|
+
raise SystemExit("answer marks require --answer-decision and --answer-file")
|
|
3422
|
+
if any((
|
|
3423
|
+
args.check,
|
|
3424
|
+
args.thread,
|
|
3425
|
+
args.comment,
|
|
3426
|
+
args.residual_file,
|
|
3427
|
+
args.acted_edit_id,
|
|
3428
|
+
args.currency_key,
|
|
3429
|
+
args.currency_disposition,
|
|
3430
|
+
args.semantic_conflict_fingerprint,
|
|
3431
|
+
args.currency_outcome,
|
|
3432
|
+
args.currency_inspected_fingerprint,
|
|
3433
|
+
args.disposition != DISPOSITION_DISPATCHED,
|
|
3434
|
+
)):
|
|
3435
|
+
raise SystemExit("answer marks cannot be combined with source actions")
|
|
3436
|
+
marked = _answer_human_decision(
|
|
3437
|
+
state, args.answer_decision, args.answer_file, now)
|
|
3438
|
+
elif (args.currency_key or args.currency_disposition or args.currency_outcome
|
|
3439
|
+
or args.currency_inspected_fingerprint):
|
|
3440
|
+
actions = sum(bool(value) for value in (
|
|
3441
|
+
args.currency_disposition, args.currency_outcome,
|
|
3442
|
+
args.currency_inspected_fingerprint))
|
|
3443
|
+
if not args.currency_key or actions != 1:
|
|
3444
|
+
raise SystemExit("currency marks require --currency-key and exactly one currency action")
|
|
3445
|
+
decision_required = _mark_branch_currency(args, state, now)
|
|
3446
|
+
if decision_required:
|
|
3447
|
+
residual = _load_needs_human_residual(
|
|
3448
|
+
args.residual_file, args.currency_key, "currency")
|
|
3449
|
+
marked = _park_human_decision(state, residual, args, now)
|
|
3450
|
+
elif args.residual_file:
|
|
3451
|
+
raise SystemExit(
|
|
3452
|
+
"--residual-file requires a currency outcome that needs a human decision")
|
|
3453
|
+
if args.currency_disposition == DISPOSITION_OPEN:
|
|
3454
|
+
_invalidate_human_source(state, args.currency_key, "currency")
|
|
3455
|
+
marked = marked or args.currency_key
|
|
3456
|
+
elif args.residual_file and not (args.check or args.thread or args.comment):
|
|
3457
|
+
if args.disposition != DISPOSITION_NEEDS_HUMAN:
|
|
3458
|
+
raise SystemExit("a residual-only mark requires --disposition needs-human")
|
|
3459
|
+
residual = _load_needs_human_residual(args.residual_file)
|
|
3460
|
+
marked = _park_human_decision(state, residual, args, now)
|
|
3461
|
+
state["last_action"] = "needs-human residual"
|
|
3462
|
+
elif args.check:
|
|
3463
|
+
head = state.get("head_sha")
|
|
3464
|
+
if not head:
|
|
3465
|
+
raise SystemExit("mark --check requires a prior snapshot (state has no head_sha)")
|
|
3466
|
+
state.setdefault("ci_dispatched", {}).setdefault(head, [])
|
|
3467
|
+
if args.disposition == DISPOSITION_OPEN:
|
|
3468
|
+
_invalidate_human_source(state, args.check, "check")
|
|
3469
|
+
state["ci_dispatched"][head] = [
|
|
3470
|
+
key for key in state["ci_dispatched"][head] if key != args.check]
|
|
3471
|
+
elif args.disposition == DISPOSITION_NEEDS_HUMAN:
|
|
3472
|
+
residual = _load_needs_human_residual(
|
|
3473
|
+
args.residual_file, args.check, "check")
|
|
3474
|
+
marked = _park_human_decision(state, residual, args, now)
|
|
3475
|
+
else:
|
|
3476
|
+
if _needs_human_source_is_covered(state, args.check, "check"):
|
|
3477
|
+
raise SystemExit(
|
|
3478
|
+
"cannot dispatch a source covered by a current human decision")
|
|
3479
|
+
if args.check not in state["ci_dispatched"][head]:
|
|
3480
|
+
state["ci_dispatched"][head].append(args.check)
|
|
3481
|
+
state["last_action"] = f"{args.disposition} check {args.check}"
|
|
3482
|
+
marked = marked or args.check
|
|
3483
|
+
elif args.thread or args.comment:
|
|
3484
|
+
item_id = args.thread or args.comment
|
|
3485
|
+
collection, id_field, label = (
|
|
3486
|
+
("threads", "thread_id", "thread") if args.thread else ("feedback", "id", "comment"))
|
|
3487
|
+
entry = state.setdefault(collection, {}).setdefault(item_id, {id_field: item_id})
|
|
3488
|
+
kind = "thread" if args.thread else entry.get("kind", "comment")
|
|
3489
|
+
if args.disposition == DISPOSITION_OPEN:
|
|
3490
|
+
entry["disposition"] = DISPOSITION_OPEN
|
|
3491
|
+
_invalidate_human_source(state, item_id, kind)
|
|
3492
|
+
entry.pop("acted_identity", None) # reopened -> next dispatch/park re-baselines
|
|
3493
|
+
elif args.disposition == DISPOSITION_NEEDS_HUMAN:
|
|
3494
|
+
residual = _load_needs_human_residual(
|
|
3495
|
+
args.residual_file, item_id, kind)
|
|
3496
|
+
marked = _park_human_decision(state, residual, args, now)
|
|
3497
|
+
else:
|
|
3498
|
+
if _needs_human_source_is_covered(state, item_id, kind):
|
|
3499
|
+
raise SystemExit(
|
|
3500
|
+
"cannot dispatch a source covered by a current human decision")
|
|
3501
|
+
entry["disposition"] = DISPOSITION_DISPATCHED
|
|
3502
|
+
if args.thread:
|
|
3503
|
+
# our reply moved the thread's last comment, so re-read it now as the baseline.
|
|
3504
|
+
ident = _mark_thread_baseline(item_id, args, state)
|
|
3505
|
+
if ident is not None:
|
|
3506
|
+
entry["acted_identity"] = ident
|
|
3507
|
+
elif args.comment and args.acted_edit_id:
|
|
3508
|
+
entry["acted_identity"] = [args.acted_edit_id]
|
|
3509
|
+
state["last_action"] = f"{args.disposition} {label} {item_id}"
|
|
3510
|
+
marked = marked or item_id
|
|
3511
|
+
if getattr(args, "invariant_key", None) and args.disposition == DISPOSITION_DISPATCHED:
|
|
3512
|
+
_record_invariant_round(state, args.invariant_key, state.get("head_sha"))
|
|
3513
|
+
if isinstance(marked, dict):
|
|
3514
|
+
print(json.dumps({"marked": marked.get("id"), "decision": marked}, separators=(",", ":")))
|
|
3515
|
+
else:
|
|
3516
|
+
print(json.dumps({"marked": marked}))
|
|
3517
|
+
|
|
3518
|
+
|
|
3519
|
+
WATCH_BOOTSTRAP_FLAGS = ("--state-dir", "--invocation-id", "--session-started-at",
|
|
3520
|
+
"--invocation-budget-seconds")
|
|
3521
|
+
WATCH_BOOTSTRAP_HINT = """\
|
|
3522
|
+
watch cannot start a babysit run: it only arms the change detector for an
|
|
3523
|
+
invocation that was already bootstrapped by a snapshot --start-invocation run
|
|
3524
|
+
(the ce-babysit-pr Step 2 bootstrap).
|
|
3525
|
+
To recover, invoke the ce-babysit-pr skill through your harness's callable
|
|
3526
|
+
skill mechanism with this PR: its instructions own the bootstrap, arming,
|
|
3527
|
+
marks, and stop protocol. Do not drive pr-snapshot directly outside that skill.
|
|
3528
|
+
Never mint the bootstrap values yourself; they come from the skill's
|
|
3529
|
+
bootstrap snapshot."""
|
|
3530
|
+
|
|
3531
|
+
|
|
3532
|
+
class _WatchHintingParser(argparse.ArgumentParser):
|
|
3533
|
+
# A bare `watch --pr N` is the observed illegal start path (an agent arming the
|
|
3534
|
+
# detector without the Step 2 bootstrap). Keep the fail-closed exit 2, but make
|
|
3535
|
+
# the refusal carry its own recovery path instead of a raw usage dump.
|
|
3536
|
+
def error(self, message):
|
|
3537
|
+
if (self.prog.endswith(" watch") and "required" in message
|
|
3538
|
+
and any(flag in message for flag in WATCH_BOOTSTRAP_FLAGS)):
|
|
3539
|
+
self.print_usage(sys.stderr)
|
|
3540
|
+
self.exit(2, f"{self.prog}: error: {message}\n\n{WATCH_BOOTSTRAP_HINT}\n")
|
|
3541
|
+
super().error(message)
|
|
3542
|
+
|
|
3543
|
+
|
|
3544
|
+
def main():
|
|
3545
|
+
p = argparse.ArgumentParser(prog="pr-snapshot")
|
|
3546
|
+
sub = p.add_subparsers(dest="cmd", required=True, parser_class=_WatchHintingParser)
|
|
3547
|
+
|
|
3548
|
+
s = sub.add_parser("snapshot")
|
|
3549
|
+
s.add_argument("--pr", type=int, required=True)
|
|
3550
|
+
s.add_argument("--repo", default=None)
|
|
3551
|
+
s.add_argument("--state-dir", required=True)
|
|
3552
|
+
s.add_argument("--fetch-file", default=None)
|
|
3553
|
+
sg = s.add_mutually_exclusive_group()
|
|
3554
|
+
sg.add_argument("--start-invocation", action="store_true",
|
|
3555
|
+
help="mint one new fixed budget (first snapshot only)")
|
|
3556
|
+
sg.add_argument("--reset-session", action="store_true",
|
|
3557
|
+
help=argparse.SUPPRESS) # deprecated alias for --start-invocation
|
|
3558
|
+
sg.add_argument("--continue-invocation", action="store_true",
|
|
3559
|
+
help="carry the current fixed budget into a managed-stack layer state dir")
|
|
3560
|
+
s.add_argument("--invocation-id", default=None,
|
|
3561
|
+
help="token emitted by the first snapshot; required on every resume")
|
|
3562
|
+
s.add_argument("--session-started-at", type=_session_started_at,
|
|
3563
|
+
help="fixed anchor emitted by the first snapshot; required for a new state dir")
|
|
3564
|
+
s.add_argument("--invocation-budget-seconds", type=float,
|
|
3565
|
+
help=f"fixed total budget; first snapshot default {DEFAULT_INVOCATION_BUDGET_SECONDS}")
|
|
3566
|
+
s.add_argument("--continue-dead-time-seconds", type=float, default=None,
|
|
3567
|
+
help="carry the prior layer's accumulated dead time into a managed-stack layer "
|
|
3568
|
+
"(with --continue-invocation), so the shared active-time budget stays correct")
|
|
3569
|
+
s.set_defaults(func=cmd_snapshot)
|
|
3570
|
+
|
|
3571
|
+
m = sub.add_parser("mark")
|
|
3572
|
+
m.add_argument("--pr", type=int, default=0)
|
|
3573
|
+
m.add_argument("--repo", default=None)
|
|
3574
|
+
m.add_argument("--state-dir", required=True)
|
|
3575
|
+
m.add_argument("--invocation-id", required=True,
|
|
3576
|
+
help="token emitted by the invocation's first snapshot")
|
|
3577
|
+
m.add_argument("--session-started-at", type=_session_started_at, required=True,
|
|
3578
|
+
help="fixed anchor emitted by the invocation's first snapshot")
|
|
3579
|
+
m.add_argument("--invocation-budget-seconds", type=float, required=True,
|
|
3580
|
+
help="fixed budget emitted by the invocation's first snapshot")
|
|
3581
|
+
m.add_argument("--thread", default=None)
|
|
3582
|
+
# `open` cancels a covering decision and returns the source to ordinary actionable state.
|
|
3583
|
+
m.add_argument("--disposition", choices=[DISPOSITION_NEEDS_HUMAN, DISPOSITION_DISPATCHED, DISPOSITION_OPEN], default=DISPOSITION_DISPATCHED)
|
|
3584
|
+
m.add_argument("--check", default=None)
|
|
3585
|
+
m.add_argument("--comment", default=None)
|
|
3586
|
+
m.add_argument("--fetch-file", default=None) # reuse the tick's fetch for the at-mark baseline
|
|
3587
|
+
m.add_argument("--acted-edit-id", default=None) # snapshot-time identity for a covered comment
|
|
3588
|
+
m.add_argument("--residual-file", default=None)
|
|
3589
|
+
m.add_argument("--currency-key", default=None,
|
|
3590
|
+
help="exact branch-currency observation key emitted by snapshot")
|
|
3591
|
+
m.add_argument("--currency-disposition",
|
|
3592
|
+
choices=[DISPOSITION_OPEN, CURRENCY_CLAIMED, CURRENCY_CONFIRMED],
|
|
3593
|
+
default=None)
|
|
3594
|
+
m.add_argument("--semantic-conflict-fingerprint", default=None,
|
|
3595
|
+
help="semantic conflict identity preserved with a currency decision")
|
|
3596
|
+
m.add_argument("--currency-outcome",
|
|
3597
|
+
choices=[CURRENCY_OUTCOME_MUTATION_OBSERVED,
|
|
3598
|
+
CURRENCY_OUTCOME_PROVEN_NO_MUTATION,
|
|
3599
|
+
CURRENCY_OUTCOME_AMBIGUOUS], default=None)
|
|
3600
|
+
m.add_argument("--currency-inspected-fingerprint", default=None)
|
|
3601
|
+
m.add_argument("--answer-decision", default=None,
|
|
3602
|
+
help="exact current decision ID whose human answer is being recorded")
|
|
3603
|
+
m.add_argument("--answer-file", default=None,
|
|
3604
|
+
help="file containing the human answer to preserve until covered work moves")
|
|
3605
|
+
m.add_argument("--invariant-key", default=None,
|
|
3606
|
+
help="resolver-supplied review invariant; counted per unique head, never inferred")
|
|
3607
|
+
m.set_defaults(func=cmd_mark)
|
|
3608
|
+
|
|
3609
|
+
w = sub.add_parser("watch")
|
|
3610
|
+
w.add_argument("--pr", type=int, required=True)
|
|
3611
|
+
w.add_argument("--repo", default=None)
|
|
3612
|
+
w.add_argument("--state-dir", required=True)
|
|
3613
|
+
w.add_argument("--interval", type=float, default=150.0, help="poll cadence seconds")
|
|
3614
|
+
w.add_argument("--downstack-pr", type=int, action="append", default=[],
|
|
3615
|
+
help="lower managed-stack layer to keep probing; a new thread/comment/failing check/head there wakes downstack-actionable")
|
|
3616
|
+
w.add_argument("--downstack-fetch-file", action="append", default=[], help="N=path fixture for --downstack-pr N (tests)")
|
|
3617
|
+
w.add_argument("--settle-seconds", type=float, default=DEFAULT_SETTLE_SECONDS,
|
|
3618
|
+
help="quiet window before a merge-ready wake")
|
|
3619
|
+
w.add_argument("--feedback-coalesce-seconds", type=float, default=FEEDBACK_COALESCE_SECONDS,
|
|
3620
|
+
help="quiet window before a comment-only wake, so a burst costs one dispatch")
|
|
3621
|
+
w.add_argument("--blocked-external-drain-seconds", type=float, default=None,
|
|
3622
|
+
help="head-scoped review-quiet bound before a gated-CI handback")
|
|
3623
|
+
w.add_argument("--stop-file", default=None, help="path whose existence stops the watch")
|
|
3624
|
+
w.add_argument("--fetch-file", default=None)
|
|
3625
|
+
w.add_argument("--invocation-id", required=True,
|
|
3626
|
+
help="token emitted by the invocation's first snapshot")
|
|
3627
|
+
w.add_argument("--session-started-at", type=_session_started_at, required=True,
|
|
3628
|
+
help="fixed anchor emitted by the first snapshot")
|
|
3629
|
+
w.add_argument("--invocation-budget-seconds", type=float, required=True,
|
|
3630
|
+
help="fixed budget emitted by the invocation's first snapshot")
|
|
3631
|
+
w.set_defaults(func=cmd_watch)
|
|
3632
|
+
|
|
3633
|
+
args = p.parse_args()
|
|
3634
|
+
if (args.cmd in ("snapshot", "watch")
|
|
3635
|
+
and args.invocation_budget_seconds is not None
|
|
3636
|
+
and args.invocation_budget_seconds <= 0):
|
|
3637
|
+
p.error("--invocation-budget-seconds must be positive")
|
|
3638
|
+
if (args.cmd == "watch" and args.blocked_external_drain_seconds is not None
|
|
3639
|
+
and args.blocked_external_drain_seconds <= 0):
|
|
3640
|
+
p.error("--blocked-external-drain-seconds must be positive")
|
|
3641
|
+
if args.cmd == "snapshot":
|
|
3642
|
+
starting = args.start_invocation or args.reset_session
|
|
3643
|
+
continuing = args.continue_invocation
|
|
3644
|
+
if starting and (args.invocation_id or args.session_started_at):
|
|
3645
|
+
p.error("the first snapshot cannot combine --start-invocation with resume fields")
|
|
3646
|
+
if continuing and not (args.invocation_id and args.session_started_at
|
|
3647
|
+
and args.invocation_budget_seconds):
|
|
3648
|
+
p.error("--continue-invocation requires --invocation-id, --session-started-at, and --invocation-budget-seconds")
|
|
3649
|
+
if not starting and not continuing and not (args.invocation_id and args.session_started_at
|
|
3650
|
+
and args.invocation_budget_seconds):
|
|
3651
|
+
p.error("snapshot requires --start-invocation or --invocation-id")
|
|
3652
|
+
args.func(args)
|
|
3653
|
+
|
|
3654
|
+
|
|
3655
|
+
if __name__ == "__main__":
|
|
3656
|
+
main()
|