claude-code-session-manager 0.98.0 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/cli.cjs +16 -4
- package/bin/node-floor.cjs +41 -0
- package/dist/assets/{DataModel-DCEgMTjH.js → DataModel-HdR0AxFq.js} +1 -1
- package/dist/assets/{History-Bfdoywlh.js → History-Dr4Jr_5z.js} +2 -2
- package/dist/assets/{Hooks-CKDJjTJV.js → Hooks-NhrvH3JP.js} +3 -3
- package/dist/assets/{Library-7akfgy67.js → Library-Dv_EtIMr.js} +1 -1
- package/dist/assets/{MarkdownEditor-DDesiqet.js → MarkdownEditor-DTKDWV_n.js} +1 -1
- package/dist/assets/{McpServers-D4lZtXof.js → McpServers-M034G9B3.js} +2 -2
- package/dist/assets/{Memory-CeI8G1w5.js → Memory-DzF1B4gx.js} +6 -6
- package/dist/assets/{Permissions-BGleLja4.js → Permissions-DrgbSGWl.js} +3 -3
- package/dist/assets/{Plugins-P4eCXy_S.js → Plugins-Bd3ef35w.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BFczzI1B.js → ProvenanceBadge-DcBHT1Iy.js} +1 -1
- package/dist/assets/{SaveBar-l9jnEMES.js → SaveBar-rimJem-Y.js} +1 -1
- package/dist/assets/Scheduler-DjgFJl2w.js +16 -0
- package/dist/assets/{ScopeSwitcher-CjJi8ST5.js → ScopeSwitcher-CnAzTBrl.js} +1 -1
- package/dist/assets/Settings-Dc1F_sSg.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CjbO01Sg.js → SkillReferenceGraph-B9aZDecM.js} +1 -1
- package/dist/assets/{Skills-BxZ9_fCR.js → Skills-DRXd18gu.js} +2 -2
- package/dist/assets/{SystemPrompt-D8vfFb8b.js → SystemPrompt-ahy5-Fwe.js} +1 -1
- package/dist/assets/{TagLibrary-2vzUo7YL.js → TagLibrary-C_tToDeL.js} +1 -1
- package/dist/assets/{TiptapBody-B8ZfbK90.js → TiptapBody-DvdefS6K.js} +1 -1
- package/dist/assets/{Toggle-BbW4nGGf.js → Toggle-6Jl5Njc6.js} +1 -1
- package/dist/assets/{index-CKH5Uxik.css → index-Bta-hwud.css} +1 -1
- package/dist/assets/{index-Cd98Q1lD.js → index-vx8O73l8.js} +497 -502
- package/dist/assets/{settingsSchema-IhUfucCF.js → settingsSchema-D6iRguNV.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +25 -29
- package/plugins/CLAUDE.md +6 -6
- package/plugins/session-manager-dev/skills/builder/4-manual/SKILL.md +21 -19
- package/plugins/session-manager-dev/skills/builder/SKILL.md +3 -3
- package/plugins/session-manager-dev/skills/develop/SKILL.md +254 -510
- package/plugins/session-manager-dev/skills/develop/standards.md +17 -22
- package/scripts/README.md +3 -11
- package/scripts/audit-ops-hygiene.cjs +3 -3
- package/scripts/hooks/lib/guard-prd-writes-policy.cjs +1 -1
- package/scripts/hooks/lib/guard-self-schedule-policy.cjs +1 -1
- package/scripts/mint-epic.cjs +2 -3
- package/scripts/scheduler-mcp-server.cjs +36 -7
- package/src/main/agentLibrary.cjs +22 -2
- package/src/main/build-info.json +4 -4
- package/src/main/config.cjs +41 -38
- package/src/main/docEdit.cjs +4 -1
- package/src/main/files.cjs +2 -5
- package/src/main/health.cjs +1 -1
- package/src/main/index.cjs +23 -10
- package/src/main/ipcSchemas.cjs +28 -35
- package/src/main/lib/agentPersonaSchema.cjs +13 -2
- package/src/main/lib/atomicFs.cjs +116 -0
- package/src/main/lib/branchSweep.cjs +13 -12
- package/src/main/lib/buildTarget.cjs +3 -3
- package/src/main/lib/claudeCliCaps.cjs +129 -0
- package/src/main/lib/credentials.cjs +2 -4
- package/src/main/lib/crossProjectFeedback.cjs +3 -3
- package/src/main/lib/cwdClassify.cjs +15 -3
- package/src/main/lib/definitionOfDone.cjs +207 -125
- package/src/main/lib/dodDrainHook.cjs +11 -5
- package/src/main/lib/epicMint.cjs +2 -4
- package/src/main/lib/epicStatusMirror.cjs +8 -10
- package/src/main/lib/epicWorktreeProjectConfig.cjs +2 -4
- package/src/main/lib/gateAuthority.cjs +96 -0
- package/src/main/lib/gitExec.cjs +69 -0
- package/src/main/lib/gitWorktree.cjs +656 -92
- package/src/main/lib/instanceLock.cjs +5 -8
- package/src/main/lib/launchFailure.cjs +2 -3
- package/src/main/lib/macroLibrary.cjs +386 -0
- package/src/main/lib/mcpToolCatalog.cjs +26 -17
- package/src/main/lib/needsReviewLedger.cjs +1 -1
- package/src/main/lib/opsErrorLog.cjs +11 -1
- package/src/main/lib/opsOwnership.cjs +0 -5
- package/src/main/lib/pidAlive.cjs +32 -0
- package/src/main/lib/prdCreate.cjs +121 -12
- package/src/main/lib/prdDisposition.cjs +2 -2
- package/src/main/lib/prdGateFiles.cjs +284 -0
- package/src/main/lib/prdLocations.cjs +61 -0
- package/src/main/lib/prdMigration.cjs +2 -3
- package/src/main/lib/prdSizing.cjs +2 -1
- package/src/main/lib/promptSessionSchema.cjs +10 -2
- package/src/main/lib/rcaReport.cjs +4 -6
- package/src/main/lib/reaperHelpers.cjs +8 -1
- package/src/main/lib/reservationExpiry.cjs +6 -1
- package/src/main/lib/reviewNotice.cjs +263 -0
- package/src/main/lib/runClaudeP.cjs +4 -1
- package/src/main/lib/schedulerPaths.cjs +22 -2
- package/src/main/lib/sessionSlots.cjs +2 -4
- package/src/main/lib/shippedPersonaSeeds.cjs +179 -0
- package/src/main/lib/timeoutShim.cjs +123 -0
- package/src/main/lib/timeoutShimScript.cjs +275 -0
- package/src/main/lib/upgradeDrain.cjs +4 -10
- package/src/main/lib/watchdogHelpers.cjs +12 -25
- package/src/main/lib/workTypeLibrary.cjs +7 -1
- package/src/main/promptSessionEvents.cjs +37 -13
- package/src/main/scheduler.cjs +910 -198
- package/src/main/seedAgentPersonas.cjs +278 -23
- package/src/main/supervisor.cjs +4 -1
- package/src/main/templates/PRD_AUTHORING.md +126 -406
- package/src/preload/__tests__/preload-surface.test.cjs +68 -0
- package/src/preload/api.d.ts +41 -94
- package/src/preload/index.cjs +14 -9
- package/src/seed/agents/architect.md +1 -0
- package/src/seed/agents/dev-lead.md +17 -18
- package/src/seed/agents/project-home-builder.md +1 -0
- package/src/seed/agents/validator.md +10 -4
- package/dist/assets/HostBilko-CDVHE0hT.js +0 -1
- package/dist/assets/Scheduler-C_phpeUS.js +0 -16
- package/dist/assets/Settings-BIwZ3FGQ.js +0 -3
- package/src/main/__tests__/activeIndexMerge.test.cjs +0 -235
- package/src/main/__tests__/agentEffortResolve.test.cjs +0 -117
- package/src/main/__tests__/agentLibrary.test.cjs +0 -276
- package/src/main/__tests__/agentModelResolve.test.cjs +0 -311
- package/src/main/__tests__/agentOverlayWrite.test.cjs +0 -95
- package/src/main/__tests__/bilkoHost-deriveSlug.test.cjs +0 -26
- package/src/main/__tests__/bilkoHost-integration.test.cjs +0 -118
- package/src/main/__tests__/bilkoHostCore.test.cjs +0 -72
- package/src/main/__tests__/broadcastCoalescer.test.cjs +0 -122
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +0 -120
- package/src/main/__tests__/chat-dead-channels.test.cjs +0 -63
- package/src/main/__tests__/chat-exit-close-race.test.cjs +0 -146
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +0 -139
- package/src/main/__tests__/chat-preamble-anchors.test.cjs +0 -100
- package/src/main/__tests__/chat-queue.test.cjs +0 -97
- package/src/main/__tests__/chat-stop-signal.test.cjs +0 -89
- package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +0 -125
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +0 -252
- package/src/main/__tests__/classifyPromptTicket.test.cjs +0 -101
- package/src/main/__tests__/classifyTranscriptLine.test.cjs +0 -201
- package/src/main/__tests__/computeDepHistorySatisfaction.test.cjs +0 -66
- package/src/main/__tests__/config-readText-bounded.test.cjs +0 -84
- package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +0 -59
- package/src/main/__tests__/crossProjectFeedback.test.cjs +0 -334
- package/src/main/__tests__/crossProjectFeedbackRoutes.test.cjs +0 -161
- package/src/main/__tests__/dep-orphan-archive-health.test.cjs +0 -77
- package/src/main/__tests__/develop-skill-failure-modes.test.cjs +0 -70
- package/src/main/__tests__/docEdit.test.cjs +0 -244
- package/src/main/__tests__/dod-batchkey.test.cjs +0 -183
- package/src/main/__tests__/dod-drain-hook.test.cjs +0 -302
- package/src/main/__tests__/dod-report.test.cjs +0 -304
- package/src/main/__tests__/dod-reverify.test.cjs +0 -285
- package/src/main/__tests__/epicContextDigest.test.cjs +0 -174
- package/src/main/__tests__/epicMint.test.cjs +0 -332
- package/src/main/__tests__/epicMintTelemetryTap.test.cjs +0 -64
- package/src/main/__tests__/epicStatusMirror.test.cjs +0 -110
- package/src/main/__tests__/epicValidationHook.test.cjs +0 -291
- package/src/main/__tests__/exchanges.test.cjs +0 -122
- package/src/main/__tests__/exchangesPromptId.test.cjs +0 -61
- package/src/main/__tests__/extractJson.test.cjs +0 -51
- package/src/main/__tests__/files-reject-credentials.test.cjs +0 -40
- package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +0 -556
- package/src/main/__tests__/flatPrdTickSweep.test.cjs +0 -110
- package/src/main/__tests__/health-build-freshness.test.cjs +0 -39
- package/src/main/__tests__/health-claude-md-budget.test.cjs +0 -57
- package/src/main/__tests__/health-credentials.test.cjs +0 -81
- package/src/main/__tests__/health-delegation-chain.test.cjs +0 -124
- package/src/main/__tests__/health-per-project-stall.test.cjs +0 -84
- package/src/main/__tests__/health-prd-migration.test.cjs +0 -37
- package/src/main/__tests__/health-queue-dispatch.test.cjs +0 -135
- package/src/main/__tests__/health-starve-escalation.test.cjs +0 -94
- package/src/main/__tests__/health-tick-liveness.test.cjs +0 -179
- package/src/main/__tests__/health-usage-poller.test.cjs +0 -144
- package/src/main/__tests__/health-worktree-cap-blocked.test.cjs +0 -65
- package/src/main/__tests__/heapSnapshot.test.cjs +0 -121
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +0 -313
- package/src/main/__tests__/historyDashboard.test.cjs +0 -163
- package/src/main/__tests__/historyRollup.test.cjs +0 -333
- package/src/main/__tests__/intradayRefresh.test.cjs +0 -39
- package/src/main/__tests__/ipcSchemas-dependsOn.test.cjs +0 -39
- package/src/main/__tests__/kg-augment.test.cjs +0 -195
- package/src/main/__tests__/loadGateDetailTick.test.cjs +0 -31
- package/src/main/__tests__/machineProfile.test.cjs +0 -152
- package/src/main/__tests__/mcpStatus.test.cjs +0 -61
- package/src/main/__tests__/memoryAggregate.test.cjs +0 -109
- package/src/main/__tests__/memoryStale.test.cjs +0 -88
- package/src/main/__tests__/needsReviewLedger.test.cjs +0 -162
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +0 -25
- package/src/main/__tests__/opsErrorLog.test.cjs +0 -109
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +0 -173
- package/src/main/__tests__/personaMerge.test.cjs +0 -169
- package/src/main/__tests__/planValidator.test.cjs +0 -125
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +0 -176
- package/src/main/__tests__/prd-group-allocator.test.cjs +0 -119
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +0 -70
- package/src/main/__tests__/prdAdminRoutes.test.cjs +0 -718
- package/src/main/__tests__/prdAgentType.test.cjs +0 -103
- package/src/main/__tests__/prdAuthoringSeed.test.cjs +0 -39
- package/src/main/__tests__/prdCreate.test.cjs +0 -979
- package/src/main/__tests__/prdCreateAdoption.test.cjs +0 -205
- package/src/main/__tests__/prdCreateDisposition.test.cjs +0 -201
- package/src/main/__tests__/prdCreatePlanId.test.cjs +0 -132
- package/src/main/__tests__/prdFrontmatterAgentType.test.cjs +0 -117
- package/src/main/__tests__/prdFrontmatterDependsOn.test.cjs +0 -136
- package/src/main/__tests__/prdFrontmatterDisposition.test.cjs +0 -125
- package/src/main/__tests__/prdFrontmatterQuietMachine.test.cjs +0 -108
- package/src/main/__tests__/prdLocations.test.cjs +0 -195
- package/src/main/__tests__/prdLocationsArchived.test.cjs +0 -201
- package/src/main/__tests__/prdMigration.test.cjs +0 -349
- package/src/main/__tests__/prdMigrationLegacyAdopt.test.cjs +0 -91
- package/src/main/__tests__/prdParserHighWater.test.cjs +0 -74
- package/src/main/__tests__/prdParserSourcePromptId.test.cjs +0 -65
- package/src/main/__tests__/prdSetDisposition.test.cjs +0 -222
- package/src/main/__tests__/prdSizing.test.cjs +0 -106
- package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +0 -118
- package/src/main/__tests__/prdUpdateDependsOn.test.cjs +0 -160
- package/src/main/__tests__/proc-role-env.test.cjs +0 -125
- package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +0 -304
- package/src/main/__tests__/procname-sm-processes.test.cjs +0 -127
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +0 -177
- package/src/main/__tests__/projectPages.test.cjs +0 -137
- package/src/main/__tests__/promptSessionEvents.test.cjs +0 -234
- package/src/main/__tests__/promptSessionSchema.test.cjs +0 -101
- package/src/main/__tests__/promptSessionTranscript.test.cjs +0 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +0 -159
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +0 -282
- package/src/main/__tests__/pty-session-open-telemetry.test.cjs +0 -96
- package/src/main/__tests__/pty-write-result.test.cjs +0 -46
- package/src/main/__tests__/queue-health-verdict.test.cjs +0 -170
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +0 -286
- package/src/main/__tests__/queue-starvation-per-project.test.cjs +0 -147
- package/src/main/__tests__/queueHistory.test.cjs +0 -355
- package/src/main/__tests__/queueOps-interactive-ac-lint.test.cjs +0 -65
- package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +0 -65
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +0 -211
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +0 -128
- package/src/main/__tests__/rcaReport.test.cjs +0 -266
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +0 -119
- package/src/main/__tests__/reconcileTiming.test.cjs +0 -135
- package/src/main/__tests__/runLogRetention.test.cjs +0 -489
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +0 -26
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +0 -58
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +0 -191
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +0 -89
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +0 -225
- package/src/main/__tests__/runVerify.test.cjs +0 -1784
- package/src/main/__tests__/scheduleJobSchema.test.cjs +0 -127
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +0 -65
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +0 -277
- package/src/main/__tests__/scheduleJobTransitionsGrep.test.cjs +0 -59
- package/src/main/__tests__/scheduleJobTransitionsTelemetryTap.test.cjs +0 -72
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +0 -199
- package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +0 -143
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +0 -105
- package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +0 -102
- package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +0 -155
- package/src/main/__tests__/scheduler-autofix-outcome.test.cjs +0 -188
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +0 -439
- package/src/main/__tests__/scheduler-autopromote.test.cjs +0 -51
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +0 -101
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +0 -107
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +0 -153
- package/src/main/__tests__/scheduler-broadcast-reconcile.test.cjs +0 -121
- package/src/main/__tests__/scheduler-clear-queue-history.test.cjs +0 -134
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +0 -244
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +0 -182
- package/src/main/__tests__/scheduler-cross-project-batch.test.cjs +0 -43
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +0 -168
- package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +0 -58
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +0 -81
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +0 -227
- package/src/main/__tests__/scheduler-failed-autoreset.test.cjs +0 -121
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +0 -229
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +0 -75
- package/src/main/__tests__/scheduler-fix-plan-path.test.cjs +0 -119
- package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +0 -46
- package/src/main/__tests__/scheduler-foreign-wip-manifest.test.cjs +0 -78
- package/src/main/__tests__/scheduler-gate-shadow.test.cjs +0 -119
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +0 -390
- package/src/main/__tests__/scheduler-heal-refusal.test.cjs +0 -61
- package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +0 -80
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +0 -323
- package/src/main/__tests__/scheduler-integration-failure-stamp.test.cjs +0 -41
- package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +0 -63
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +0 -123
- package/src/main/__tests__/scheduler-job-budget.test.cjs +0 -172
- package/src/main/__tests__/scheduler-job-overrun.test.cjs +0 -175
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +0 -199
- package/src/main/__tests__/scheduler-leftover-fields.test.cjs +0 -52
- package/src/main/__tests__/scheduler-leftover-quarantine.test.cjs +0 -199
- package/src/main/__tests__/scheduler-looks-done.test.cjs +0 -537
- package/src/main/__tests__/scheduler-manual-pause.test.cjs +0 -118
- package/src/main/__tests__/scheduler-mechanical-recovery.test.cjs +0 -245
- package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +0 -46
- package/src/main/__tests__/scheduler-mutate-reentrancy.test.cjs +0 -117
- package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +0 -197
- package/src/main/__tests__/scheduler-never-stop.test.cjs +0 -157
- package/src/main/__tests__/scheduler-no-dead-end-status.test.cjs +0 -152
- package/src/main/__tests__/scheduler-no-orphan-run-dir.test.cjs +0 -81
- package/src/main/__tests__/scheduler-notify-originating-tab-transcript.test.cjs +0 -87
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +0 -343
- package/src/main/__tests__/scheduler-periodic-reverify-guard.test.cjs +0 -217
- package/src/main/__tests__/scheduler-porcelain-rename.test.cjs +0 -164
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +0 -161
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +0 -178
- package/src/main/__tests__/scheduler-quarantine-autoresolve.test.cjs +0 -165
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +0 -257
- package/src/main/__tests__/scheduler-rate-limit-cooldown-freshness.test.cjs +0 -123
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +0 -152
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +0 -156
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +0 -754
- package/src/main/__tests__/scheduler-reaper-helpers-basics.test.cjs +0 -87
- package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +0 -100
- package/src/main/__tests__/scheduler-reconcile-history-backfill.test.cjs +0 -105
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +0 -203
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +0 -247
- package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +0 -77
- package/src/main/__tests__/scheduler-resume-recovery.test.cjs +0 -254
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +0 -115
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +0 -380
- package/src/main/__tests__/scheduler-sigterm-commit.test.cjs +0 -43
- package/src/main/__tests__/scheduler-stall-per-project.test.cjs +0 -126
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +0 -154
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +0 -252
- package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +0 -185
- package/src/main/__tests__/scheduler-stuck-failed-escalation.test.cjs +0 -136
- package/src/main/__tests__/scheduler-supervisor-record.test.cjs +0 -81
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +0 -54
- package/src/main/__tests__/scheduler-tick-wedge.test.cjs +0 -172
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +0 -141
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +0 -62
- package/src/main/__tests__/scheduler-utilization-hold.test.cjs +0 -89
- package/src/main/__tests__/scheduler-verify-prd-path.test.cjs +0 -109
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +0 -234
- package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +0 -120
- package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +0 -105
- package/src/main/__tests__/schedulerBatchRootBlocker.test.cjs +0 -117
- package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +0 -110
- package/src/main/__tests__/seedAgentPersonas.test.cjs +0 -184
- package/src/main/__tests__/seedSchedulerMcp.test.cjs +0 -211
- package/src/main/__tests__/seedStatus.test.cjs +0 -100
- package/src/main/__tests__/seedValidatorPersona.test.cjs +0 -116
- package/src/main/__tests__/stop-signal-anchor.test.cjs +0 -75
- package/src/main/__tests__/telemetryClient.test.cjs +0 -1055
- package/src/main/__tests__/telemetryContract.test.cjs +0 -930
- package/src/main/__tests__/telemetrySettings.test.cjs +0 -210
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +0 -249
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +0 -124
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +0 -241
- package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +0 -154
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +0 -206
- package/src/main/__tests__/uniquePrdNumbers.test.cjs +0 -153
- package/src/main/__tests__/usageSingleFlight.test.cjs +0 -169
- package/src/main/__tests__/validationSentinels.test.cjs +0 -84
- package/src/main/__tests__/workTypeLibrary.test.cjs +0 -89
- package/src/main/bilkoHost.cjs +0 -314
- package/src/main/bilkoHostCore.cjs +0 -89
- package/src/main/lib/__tests__/active-sessions.test.cjs +0 -251
- package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +0 -179
- package/src/main/lib/__tests__/agentPersonaSchema.test.cjs +0 -67
- package/src/main/lib/__tests__/auditLog.test.cjs +0 -38
- package/src/main/lib/__tests__/bootSelfHeal.test.cjs +0 -107
- package/src/main/lib/__tests__/branchSweep.test.cjs +0 -164
- package/src/main/lib/__tests__/buildIdentity.test.cjs +0 -121
- package/src/main/lib/__tests__/buildTarget.test.cjs +0 -52
- package/src/main/lib/__tests__/childWithLog.test.cjs +0 -321
- package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +0 -87
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +0 -103
- package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +0 -115
- package/src/main/lib/__tests__/cwdClassify.test.cjs +0 -111
- package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +0 -95
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +0 -1175
- package/src/main/lib/__tests__/dispatchLoop.test.cjs +0 -63
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +0 -244
- package/src/main/lib/__tests__/ephemeralCwd.test.cjs +0 -91
- package/src/main/lib/__tests__/epicDelegationStats.test.cjs +0 -137
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +0 -283
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +0 -196
- package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +0 -163
- package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +0 -136
- package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +0 -130
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +0 -117
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +0 -137
- package/src/main/lib/__tests__/fixChainDepth.test.cjs +0 -40
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +0 -13
- package/src/main/lib/__tests__/fixtures/scheduler-machine.json.corrupt-1789147548 +0 -34
- package/src/main/lib/__tests__/gateFixtures.json +0 -20
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +0 -69
- package/src/main/lib/__tests__/gitWorktree.test.cjs +0 -1478
- package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +0 -107
- package/src/main/lib/__tests__/gitWorktreeSalvageDelta.test.cjs +0 -153
- package/src/main/lib/__tests__/guardShims.test.cjs +0 -158
- package/src/main/lib/__tests__/importReferences.spec.cjs +0 -56
- package/src/main/lib/__tests__/instanceLock.test.cjs +0 -173
- package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +0 -78
- package/src/main/lib/__tests__/jobWorktree.test.cjs +0 -199
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +0 -82
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +0 -133
- package/src/main/lib/__tests__/launchFailure.test.cjs +0 -220
- package/src/main/lib/__tests__/loadGate.test.cjs +0 -303
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +0 -214
- package/src/main/lib/__tests__/loopDelay.test.cjs +0 -68
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +0 -107
- package/src/main/lib/__tests__/modelCatalog.test.cjs +0 -202
- package/src/main/lib/__tests__/opsOwnership.test.cjs +0 -113
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +0 -328
- package/src/main/lib/__tests__/opsRootNestedWrite.test.cjs +0 -51
- package/src/main/lib/__tests__/opsRootResolve.test.cjs +0 -149
- package/src/main/lib/__tests__/prdDeclaredPaths.test.cjs +0 -82
- package/src/main/lib/__tests__/prdDisposition.test.cjs +0 -224
- package/src/main/lib/__tests__/procIdentity.test.cjs +0 -119
- package/src/main/lib/__tests__/procName.test.cjs +0 -92
- package/src/main/lib/__tests__/projectBriefCore.test.cjs +0 -216
- package/src/main/lib/__tests__/projectRootResolve.test.cjs +0 -148
- package/src/main/lib/__tests__/queueHealth.test.cjs +0 -58
- package/src/main/lib/__tests__/queueStoreAtomicWrite.test.cjs +0 -88
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +0 -190
- package/src/main/lib/__tests__/quietMachineLease.test.cjs +0 -39
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +0 -88
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +0 -577
- package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +0 -312
- package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +0 -213
- package/src/main/lib/__tests__/schedulerBatchLaunchHold.test.cjs +0 -125
- package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +0 -127
- package/src/main/lib/__tests__/schedulerBatchQuietMachine.test.cjs +0 -109
- package/src/main/lib/__tests__/schedulerMcpServerHeadlessRefusal.test.cjs +0 -71
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +0 -216
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +0 -183
- package/src/main/lib/__tests__/schedulerPaths.test.cjs +0 -226
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +0 -133
- package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +0 -56
- package/src/main/lib/__tests__/sessionSlots.test.cjs +0 -144
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +0 -626
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +0 -134
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +0 -136
- package/src/main/lib/__tests__/telemetryCounters.test.cjs +0 -57
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +0 -98
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +0 -200
- package/src/main/lib/__tests__/toolUseClassify.test.cjs +0 -53
- package/src/main/lib/__tests__/updateCheck.test.cjs +0 -63
- package/src/main/lib/__tests__/upgradeDrain.test.cjs +0 -130
- package/src/main/lib/__tests__/usageCircuit.test.cjs +0 -354
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +0 -441
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +0 -266
- package/src/main/lib/kgExchangePairing.cjs +0 -75
package/src/main/scheduler.cjs
CHANGED
|
@@ -55,8 +55,15 @@ const { execFile, execFileSync } = require('node:child_process');
|
|
|
55
55
|
const { ipcMain } = require('electron');
|
|
56
56
|
const billing = require('./usage.cjs');
|
|
57
57
|
const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
58
|
+
const { ensureTimeoutShim, withTimeoutShimOnPath } = require('./lib/timeoutShim.cjs');
|
|
58
59
|
const supervisor = require('./supervisor.cjs');
|
|
59
60
|
const { resolveClaudeBin, claudeSpawnTarget, probeClaudeVersion } = require('./lib/claudeBin.cjs');
|
|
61
|
+
const { headlessPermissionArgs, ensureCliCapsProbed } = require('./lib/claudeCliCaps.cjs');
|
|
62
|
+
// Fire-and-forget priming — by the time the first job dispatches, the cached
|
|
63
|
+
// probe result is almost always already warm for buildClaudeSpawnArgs's sync
|
|
64
|
+
// callers; async spawn sites still `await ensureCliCapsProbed()` themselves
|
|
65
|
+
// as the authoritative guarantee.
|
|
66
|
+
ensureCliCapsProbed().catch(() => {});
|
|
60
67
|
const launchFailure = require('./lib/launchFailure.cjs');
|
|
61
68
|
const { appendError } = require('./lib/opsErrorLog.cjs');
|
|
62
69
|
const { readTail } = require('./lib/fileTail.cjs');
|
|
@@ -69,6 +76,7 @@ const { resolveProjectRoot } = require('./lib/opsOwnership.cjs');
|
|
|
69
76
|
const { sweepStrandedJobBranches } = require('./lib/branchSweep.cjs');
|
|
70
77
|
const { detectRateLimitInLog } = require('./lib/rateLimitDetect.cjs');
|
|
71
78
|
const { stripAppOwnedChurn } = require('./lib/jobDirtFilter.cjs');
|
|
79
|
+
const { GATE_AUTHORITY_VERDICTS, decideGateAuthority } = require('./lib/gateAuthority.cjs');
|
|
72
80
|
const { resolveBindingRateLimitReset } = require('./lib/rateLimitWindow.cjs');
|
|
73
81
|
const { isResetFresh, bindingWindow, degradedBudget } = require('./lib/usageCircuit.cjs');
|
|
74
82
|
const { computeQueueHealth } = require('./lib/queueHealth.cjs');
|
|
@@ -79,7 +87,8 @@ const { createBroadcastCoalescer } = require('./lib/broadcastCoalescer.cjs');
|
|
|
79
87
|
const prdParser = require('./scheduler/prdParser.cjs');
|
|
80
88
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
81
89
|
const { enqueueExternalPrompt } = require('./chatRunner.cjs');
|
|
82
|
-
const { appendResponseEventIfKnown } = require('./promptSessionEvents.cjs');
|
|
90
|
+
const { appendResponseEventIfKnown, appendResponseEventWithReason } = require('./promptSessionEvents.cjs');
|
|
91
|
+
const { buildReviewNotice, selectDueReviewNotices, formatReviewNotice, holdMsFromEnv } = require('./lib/reviewNotice.cjs');
|
|
83
92
|
const { maybeEnqueueValidationPrompt } = require('./lib/epicValidationHook.cjs');
|
|
84
93
|
const { parseValidationSentinels } = require('./lib/validationSentinels.cjs');
|
|
85
94
|
const { hasDownstreamValidator } = require('./lib/planValidator.cjs');
|
|
@@ -145,7 +154,7 @@ const queueOps = require('./queueOps.cjs');
|
|
|
145
154
|
// Plain Node module, no Electron dependency; queuePath/prdsDir defaults already
|
|
146
155
|
// match ROOT/QUEUE_PATH below since both resolve the same ~/.claude/session-manager
|
|
147
156
|
// home-dir layout.
|
|
148
|
-
const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs, deriveProjectCwdFromPrdPath } = require('./lib/prdLocations.cjs');
|
|
157
|
+
const { resolvePrdsDirs, resolveArchivedPrdsDirs, resolvePrdWriteDir, listEpicPrdDirs, listArchivedPrdDirs, deriveProjectCwdFromPrdPath, ancestorEpicPrdDirs, ancestorEpicArchivedPrdDirs } = require('./lib/prdLocations.cjs');
|
|
149
158
|
const { ensureEpic, appendPrdCreatedEvent, readActiveIndex } = require('./lib/epicMint.cjs');
|
|
150
159
|
const agentModelResolve = require('./lib/agentModelResolve.cjs');
|
|
151
160
|
const { resolveEpicEffort, effortArgs } = require('./lib/agentEffortResolve.cjs');
|
|
@@ -177,6 +186,7 @@ const quietMachineLease = require('./lib/quietMachineLease.cjs');
|
|
|
177
186
|
const runtimeState = require('./lib/schedulerRuntimeState.cjs');
|
|
178
187
|
const jobWorktree = require('./lib/jobWorktree.cjs');
|
|
179
188
|
const gitWorktree = require('./lib/gitWorktree.cjs');
|
|
189
|
+
const sharedGitExec = require('./lib/gitExec.cjs');
|
|
180
190
|
const { buildJobWorktreeIsLive } = require('./lib/jobWorktreeBootLive.cjs');
|
|
181
191
|
const { buildTerminalOrphanIsLive } = require('./lib/jobWorktreeTerminalOrphanLive.cjs');
|
|
182
192
|
const { reconcileEpicWorktreesOnBoot } = require('./lib/epicWorktreeBoot.cjs');
|
|
@@ -272,113 +282,118 @@ const BASH_MAX_TIMEOUT_MS = 900_000; // 15 min — must stay below IDLE_OUTPUT_K
|
|
|
272
282
|
// Value lives in reaperHelpers.cjs (imported above) so the external watchdog
|
|
273
283
|
// shares the exact same budget — both increment the same j.orphanRetries field.
|
|
274
284
|
|
|
275
|
-
// Appended to every scheduled job prompt so
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
285
|
+
// Appended to every scheduled job prompt so every PRD finishes the same way:
|
|
286
|
+
// review → security review → verify → commit → verdict line. It lives here,
|
|
287
|
+
// not in each PRD, so it covers every current and future PRD. The post-run
|
|
288
|
+
// commit guard backs up the commit step: a clean exit that leaves
|
|
289
|
+
// uncommitted changes is downgraded to needs_review.
|
|
280
290
|
//
|
|
281
291
|
// PRD 1408: a plan that ends in its own trailing `validator` job (PRD
|
|
282
|
-
// 1405/1407) already
|
|
283
|
-
// work
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
292
|
+
// 1405/1407) already reviews every work item's diff once, so asking EVERY
|
|
293
|
+
// work item to also run /code-review + /security-review is duplicate work
|
|
294
|
+
// and the main cost tail on multi-file PRDs. buildFinishProtocol builds a
|
|
295
|
+
// `reviewInRun: false` variant that folds those two steps into one deferral
|
|
296
|
+
// note and renumbers the rest. Every other word is shared byte-for-byte with
|
|
297
|
+
// the default (`reviewInRun: true`, which is exactly FINISH_PROTOCOL) through
|
|
298
|
+
// the head / review-steps / tail pieces below.
|
|
299
|
+
//
|
|
300
|
+
// Plain words on purpose: short sentences, one rule each, rule first, then
|
|
301
|
+
// "Why:". Tests pin the load-bearing tokens (SYNCHRONOUSLY, background Bash,
|
|
302
|
+
// Monitor, TaskOutput, ScheduleWakeup, "no later turn", `timeout <n>`,
|
|
303
|
+
// `git add <path>`, the SCHEDULER_VERDICT lines) — keep them exact. No line
|
|
304
|
+
// of this text may START with SCHEDULER_VERDICT: or FOREIGN_WIP_PATHS: —
|
|
305
|
+
// runVerify's scanners are line-anchored.
|
|
289
306
|
function buildFinishHead(verifyStepNum) {
|
|
290
307
|
return `
|
|
291
308
|
|
|
292
309
|
---
|
|
293
|
-
# SCHEDULER FINISH PROTOCOL (
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
310
|
+
# SCHEDULER FINISH PROTOCOL (required — do this after the work above)
|
|
311
|
+
|
|
312
|
+
When every acceptance criterion above is met, do the steps below in order.
|
|
313
|
+
Do not stop before your commit lands. The commit is part of the job.
|
|
314
|
+
|
|
315
|
+
Do not stop to ask a question. No one can answer it: this run has no later
|
|
316
|
+
turn. If something is unclear, make the safest reasonable choice, say what you
|
|
317
|
+
chose in your final report, and finish. If you truly cannot go on, end with the
|
|
318
|
+
verdict line SCHEDULER_VERDICT: FAIL and the reason.
|
|
319
|
+
|
|
320
|
+
Run every check in the foreground. This rule covers the whole run, not only step ${verifyStepNum}.
|
|
321
|
+
It covers every test, typecheck, lint and build command, while you build and while you verify.
|
|
322
|
+
- Run each command SYNCHRONOUSLY and wait for it to return.
|
|
323
|
+
- Never start a check as a background task (no background Bash).
|
|
324
|
+
- Never call Monitor, TaskOutput or ScheduleWakeup to collect a result later.
|
|
325
|
+
Why: a headless \`claude -p\` run has no later turn. Nothing will deliver
|
|
326
|
+
that result, and the run ends with no commit and no verdict.
|
|
327
|
+
- Your foreground Bash limit is ${BASH_DEFAULT_TIMEOUT_MS / 1000}s by default and ${BASH_MAX_TIMEOUT_MS / 1000}s at most.
|
|
328
|
+
Wrap long commands to fit inside it, for example \`timeout ${Math.floor(BASH_MAX_TIMEOUT_MS / 1000)} npm test\`.
|
|
329
|
+
- If a check still cannot finish in time, stop and end with the verdict line
|
|
330
|
+
SCHEDULER_VERDICT: FAIL and the reason. Do not put it off.
|
|
311
331
|
|
|
312
332
|
`;
|
|
313
333
|
}
|
|
314
334
|
|
|
315
|
-
const FINISH_REVIEW_STEPS_IN_RUN = `1. CODE REVIEW — run \`/code-review --fix\` on your changes
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
2. SECURITY REVIEW — run \`/security-review\` and
|
|
320
|
-
|
|
321
|
-
path traversal
|
|
335
|
+
const FINISH_REVIEW_STEPS_IN_RUN = `1. CODE REVIEW — run \`/code-review --fix\` on your changes. Apply the fixes it finds,
|
|
336
|
+
correctness first. If you think a finding is wrong, say why in your final report.
|
|
337
|
+
Do not skip a finding silently. If \`/code-review\` is not available here,
|
|
338
|
+
review your own diff with the same care.
|
|
339
|
+
2. SECURITY REVIEW — run \`/security-review\` and fix every finding, or explain
|
|
340
|
+
why it is safe. If it is not available, check your diff yourself for
|
|
341
|
+
injection, secrets, path traversal and unsafe input handling.
|
|
322
342
|
`;
|
|
323
343
|
|
|
324
|
-
const FINISH_REVIEW_STEP_DEFERRED = `1. REVIEW —
|
|
344
|
+
const FINISH_REVIEW_STEP_DEFERRED = `1. REVIEW — skip it in this run. The plan's trailing validator job reviews code and security once, for the whole plan. Do NOT run /code-review or /security-review here.
|
|
325
345
|
`;
|
|
326
346
|
|
|
327
347
|
function buildFinishTail({ verifyStepNum, commitStepNum, verdictStepNum }) {
|
|
328
|
-
return `${verifyStepNum}. VERIFY — run the
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
348
|
+
return `${verifyStepNum}. VERIFY — run every command in the PRD's \`# Gate\` section, in order, in the
|
|
349
|
+
foreground. Each must exit 0. If the PRD has no \`# Gate\` section, run the
|
|
350
|
+
project's own typecheck, lint and test commands instead. The project's
|
|
351
|
+
CLAUDE.md names them; if it does not, find them in the repo. Do not assume npm.
|
|
352
|
+
${commitStepNum}. COMMIT — stage only the exact paths you created or changed for this PRD,
|
|
353
|
+
then commit:
|
|
354
|
+
\`git add <path> [<path>...] && git commit -m "<type>(<scope>): <summary>"\`
|
|
355
|
+
Never stage the whole tree with a wildcard or blanket git add.
|
|
356
|
+
Why: other jobs may be writing files in this same working tree right now.
|
|
357
|
+
A blanket add puts their half-done work in your commit and breaks both
|
|
358
|
+
jobs' verdicts. Always commit your own work: this rule changes what you
|
|
359
|
+
stage, never whether you commit.
|
|
360
|
+
${verdictStepNum}. VERDICT LINE — end your final report with exactly one of these lines.
|
|
361
|
+
Put nothing after it, except the FOREIGN_WIP_PATHS line described below.
|
|
341
362
|
SCHEDULER_VERDICT: PASS
|
|
342
363
|
SCHEDULER_VERDICT: FAIL <one-line reason>
|
|
343
364
|
SCHEDULER_VERDICT: BLOCKED_BY_FOREIGN_WIP
|
|
344
|
-
Print PASS only when the
|
|
345
|
-
Print FAIL
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
A job that exits with uncommitted changes is treated as INCOMPLETE and flagged
|
|
369
|
-
for review. Do NOT add work beyond the acceptance criteria — this protocol is the
|
|
370
|
-
only post-AC work. If a review finding can't be fixed within scope, commit what
|
|
371
|
-
you have, describe the finding in the commit body, and note the follow-up in your
|
|
372
|
-
final result.`;
|
|
365
|
+
- Print PASS only when the gate is green AND the commit from step ${commitStepNum} landed.
|
|
366
|
+
- Print FAIL when the gate is red or the commit could not land.
|
|
367
|
+
- Never print PASS on a red gate. Why: a false PASS ships broken work that
|
|
368
|
+
no one notices. A true PASS plus a landed commit lets the scheduler ignore
|
|
369
|
+
harmless noise in your transcript, such as grep hits for "Error", an early
|
|
370
|
+
red test run, or debug tracebacks.
|
|
371
|
+
- Print BLOCKED_BY_FOREIGN_WIP ONLY when your gate failed because of a
|
|
372
|
+
sibling job's unfinished, uncommitted file — never because of your own
|
|
373
|
+
change — AND every failing path is one this prompt already listed as
|
|
374
|
+
foreign (see the "FOREIGN WORKING-TREE STATE" section above, if present).
|
|
375
|
+
Add a second line that names every such path:
|
|
376
|
+
FOREIGN_WIP_PATHS: <path1>, <path2>, ...
|
|
377
|
+
The scheduler checks each listed path against the list it gave you. List
|
|
378
|
+
a path only if (a) this prompt told you it is foreign work, not yours,
|
|
379
|
+
and (b) it is why your gate failed. One path that is not on that list
|
|
380
|
+
turns the whole verdict back into FAIL. Claiming this for your own bug is
|
|
381
|
+
a false verdict, just like a false PASS.
|
|
382
|
+
|
|
383
|
+
A job that ends with uncommitted changes counts as INCOMPLETE and is flagged
|
|
384
|
+
for review. Do not add work beyond the acceptance criteria: these steps are the
|
|
385
|
+
only work after them. If you cannot fix a review finding within scope, commit
|
|
386
|
+
what you have, describe the finding in the commit body, and note the follow-up
|
|
387
|
+
in your final report.`;
|
|
373
388
|
}
|
|
374
389
|
|
|
375
390
|
/**
|
|
376
391
|
* buildFinishProtocol({ reviewInRun }) → string
|
|
377
392
|
*
|
|
378
393
|
* `reviewInRun: true` (default) is byte-for-byte FINISH_PROTOCOL: steps 1-5
|
|
379
|
-
* are CODE REVIEW, SECURITY REVIEW, VERIFY, COMMIT, VERDICT
|
|
394
|
+
* are CODE REVIEW, SECURITY REVIEW, VERIFY, COMMIT, VERDICT LINE.
|
|
380
395
|
* `reviewInRun: false` collapses the two review steps into one deferral note
|
|
381
|
-
* (step 1) and renumbers VERIFY/COMMIT/VERDICT
|
|
396
|
+
* (step 1) and renumbers VERIFY/COMMIT/VERDICT LINE to 2-4 — used when
|
|
382
397
|
* `hasDownstreamValidator` (lib/planValidator.cjs) finds this job's diff will
|
|
383
398
|
* be re-reviewed once by the plan's own trailing validator job, so asking
|
|
384
399
|
* every work-item to also run /code-review + /security-review inline is
|
|
@@ -1176,7 +1191,15 @@ function archivedPrdPathForJob(job) {
|
|
|
1176
1191
|
*/
|
|
1177
1192
|
async function archivedTwinExists(job) {
|
|
1178
1193
|
const slug = job && job.slug;
|
|
1179
|
-
|
|
1194
|
+
const dirs = [...listArchivedPrdDirs((job && job.cwd) || DEFAULT_PROJECT_CWD)];
|
|
1195
|
+
// Epic-in-non-git-parent case (see prdLocations.ancestorEpicDirs' header
|
|
1196
|
+
// comment): job.cwd's own project never sees the ancestor Epic's archive
|
|
1197
|
+
// dir via listArchivedPrdDirs, so also walk up from job.cwd for this
|
|
1198
|
+
// job's EXACT epicId.
|
|
1199
|
+
if (job && job.cwd && job.epicId) {
|
|
1200
|
+
dirs.push(...ancestorEpicArchivedPrdDirs(job.cwd, job.epicId));
|
|
1201
|
+
}
|
|
1202
|
+
for (const dir of dirs) {
|
|
1180
1203
|
const candidate = safeSlugPathIn(dir, slug);
|
|
1181
1204
|
if (!candidate) continue;
|
|
1182
1205
|
try {
|
|
@@ -1233,6 +1256,41 @@ async function findPrdDir(slug) {
|
|
|
1233
1256
|
return null;
|
|
1234
1257
|
}
|
|
1235
1258
|
|
|
1259
|
+
/**
|
|
1260
|
+
* Job-aware widening of findPrdDir: tries the ordinary global candidate
|
|
1261
|
+
* search first (unchanged), then a stored absolute PRD path on the row (if
|
|
1262
|
+
* the queue schema ever carries one — checked defensively), then walks up
|
|
1263
|
+
* to 3 ancestor directories above `job.cwd` for that EXACT `job.epicId`'s
|
|
1264
|
+
* `prds` dir (prdLocations.ancestorEpicPrdDirs).
|
|
1265
|
+
*
|
|
1266
|
+
* Fixes the incident where an Epic lives in a non-git PARENT folder P
|
|
1267
|
+
* (P/session-manager-operations/scheduler/epics/<epicId>/prds/*.md) but its
|
|
1268
|
+
* PRDs' frontmatter `cwd` is the sub-repo P/repo — P is never itself a
|
|
1269
|
+
* tracked project cwd, so findPrdDir's candidatePrdsDirs() never visits it
|
|
1270
|
+
* and the job was wrongly retired as `prd-missing`. epicId is matched
|
|
1271
|
+
* exactly (never a glob); every path is built with path.join, never string
|
|
1272
|
+
* concatenation with user input.
|
|
1273
|
+
*/
|
|
1274
|
+
async function findPrdDirForJob(job) {
|
|
1275
|
+
const viaGlobalSearch = await findPrdDir(job && job.slug);
|
|
1276
|
+
if (viaGlobalSearch) return viaGlobalSearch;
|
|
1277
|
+
if (job && typeof job.prdPath === 'string' && job.prdPath) {
|
|
1278
|
+
try {
|
|
1279
|
+
await fsp.access(job.prdPath);
|
|
1280
|
+
return path.dirname(job.prdPath);
|
|
1281
|
+
} catch { /* stored path stale — fall through to the ancestor walk */ }
|
|
1282
|
+
}
|
|
1283
|
+
if (job && job.cwd && job.epicId) {
|
|
1284
|
+
for (const dir of ancestorEpicPrdDirs(job.cwd, job.epicId)) {
|
|
1285
|
+
try {
|
|
1286
|
+
await fsp.access(path.join(dir, `${job.slug}.md`));
|
|
1287
|
+
return dir;
|
|
1288
|
+
} catch { /* not here — try the next ancestor */ }
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1236
1294
|
/**
|
|
1237
1295
|
* Resolve `<dir>/<slug>.md` for a directory already known to contain (or be
|
|
1238
1296
|
* about to receive) the slug, and enforce path containment. Returns the
|
|
@@ -1247,6 +1305,26 @@ function safeSlugPathIn(dir, slug) {
|
|
|
1247
1305
|
return resolved;
|
|
1248
1306
|
}
|
|
1249
1307
|
|
|
1308
|
+
/**
|
|
1309
|
+
* Realpath of a PRDs dir for the symlink-containment re-checks below, or null
|
|
1310
|
+
* when `dir` or any component between it and its project cwd (the parent of
|
|
1311
|
+
* `session-manager-operations`) is a symlink. Why: comparing a file's realpath
|
|
1312
|
+
* against the RAW dir rejected every PRD under a symlinked path (macOS tmpdir
|
|
1313
|
+
* /var → /private/var), but trusting realpath(dir) alone lets a rogue job swap
|
|
1314
|
+
* `epics/<id>/prds` for a symlink to e.g. ~/.claude and write there. Symlinks
|
|
1315
|
+
* ABOVE the project cwd (a symlinked home or project folder) stay allowed.
|
|
1316
|
+
*/
|
|
1317
|
+
async function realPrdsDir(dir) {
|
|
1318
|
+
const marker = `${path.sep}session-manager-operations${path.sep}`;
|
|
1319
|
+
const idx = (dir + path.sep).lastIndexOf(marker);
|
|
1320
|
+
const stop = idx === -1 ? path.dirname(dir) : dir.slice(0, idx);
|
|
1321
|
+
for (let p = dir; p.length > stop.length; p = path.dirname(p)) {
|
|
1322
|
+
const st = await fsp.lstat(p).catch(() => null);
|
|
1323
|
+
if (st && st.isSymbolicLink()) return null;
|
|
1324
|
+
}
|
|
1325
|
+
try { return await fsp.realpath(dir); } catch { return null; }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1250
1328
|
/**
|
|
1251
1329
|
* Locate an EXISTING `<slug>.md` across every candidate PRD dir and return
|
|
1252
1330
|
* its safe, containment-checked absolute path — or null if the slug isn't
|
|
@@ -1290,6 +1368,18 @@ async function resolveSlugOrReason(slug, cwd) {
|
|
|
1290
1368
|
return { ok: true, path: p };
|
|
1291
1369
|
}
|
|
1292
1370
|
|
|
1371
|
+
/**
|
|
1372
|
+
* Shared row-match predicate for a reset-job request: slugs carry no cwd
|
|
1373
|
+
* salt, so two different projects can independently produce the identical
|
|
1374
|
+
* slug. Both the renderer-facing `schedule:reset-job` IPC handler and
|
|
1375
|
+
* remote.resetJob (admin/MCP) go through this one predicate so a cwd-bearing
|
|
1376
|
+
* caller always resets THAT project's job, never just any row matching the
|
|
1377
|
+
* string.
|
|
1378
|
+
*/
|
|
1379
|
+
function jobMatchesSlugAndCwd(j, slug, cwd) {
|
|
1380
|
+
return j.slug === slug && (!cwd || j.cwd === cwd);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1293
1383
|
/** Actionable message for `resolveSlugOrReason`'s 'not-found' reason. */
|
|
1294
1384
|
function unknownSlugMessage(slug) {
|
|
1295
1385
|
return `unknown slug "${slug}": no PRD file with that name in any known project — call scheduler_list_prds (optionally with cwd) to see what exists`;
|
|
@@ -3618,13 +3708,17 @@ let drainActive = false;
|
|
|
3618
3708
|
|
|
3619
3709
|
function drainDeferredInvestigation() {
|
|
3620
3710
|
if (drainActive || runtimeState.investigationCount() >= MAX_CONCURRENT_INVESTIGATIONS) return;
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3711
|
+
// Skip the slug the background gate shadow is still deciding (gateShadowSlug,
|
|
3712
|
+
// set near reverifyNeedsReview's pick) — leave it queued rather than drain it
|
|
3713
|
+
// out from under the gate. Take the first OTHER entry instead, in queue order.
|
|
3714
|
+
for (const [slug, ctx] of deferredInvestigations) {
|
|
3715
|
+
if (slug === gateShadowSlug) continue;
|
|
3716
|
+
deferredInvestigations.delete(slug);
|
|
3717
|
+
spawnInvestigation(ctx.failedJob, ctx.runDir).catch((e) => {
|
|
3718
|
+
console.error('[scheduler] drained investigation error', slug, e);
|
|
3719
|
+
});
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3628
3722
|
}
|
|
3629
3723
|
let cancelToken = { cancelled: false };
|
|
3630
3724
|
// Last memory-gate observation; included in snapshot for renderer visibility.
|
|
@@ -4022,15 +4116,21 @@ async function clearPause(source) {
|
|
|
4022
4116
|
/**
|
|
4023
4117
|
* Mutate a job in place to "pending" with cleared run metadata.
|
|
4024
4118
|
*
|
|
4025
|
-
* Refuses (no-ops, returns false) on a job already in
|
|
4026
|
-
*
|
|
4027
|
-
*
|
|
4028
|
-
*
|
|
4029
|
-
*
|
|
4030
|
-
*
|
|
4031
|
-
*
|
|
4032
|
-
*
|
|
4033
|
-
*
|
|
4119
|
+
* Refuses (no-ops, returns false) on a job already in one of two terminal
|
|
4120
|
+
* statuses, unless opts.force is true:
|
|
4121
|
+
* - 'completed': the work already shipped. Resetting it re-fires the PRD
|
|
4122
|
+
* and re-executes already-shipped work (the false-failure class PRD
|
|
4123
|
+
* 812-workbench-review-nits-cleanup demonstrated: a completed job was
|
|
4124
|
+
* reset to pending and re-ran a correct no-op that then got flagged
|
|
4125
|
+
* needs_review).
|
|
4126
|
+
* - 'skipped': the scheduler or a human already chose not to run this job.
|
|
4127
|
+
* force:true is the deliberate override for that choice.
|
|
4128
|
+
* resetRefusalMessage() (right below) names the one of these two that
|
|
4129
|
+
* applies, for the two external callers that report a refusal to a caller.
|
|
4130
|
+
* All internal call sites operate on jobs that are still 'running'/'failed'
|
|
4131
|
+
* at the point they call this, so the guard is a no-op for them; only an
|
|
4132
|
+
* external reset request (IPC/admin API) can target an already-'completed'
|
|
4133
|
+
* or already-'skipped' job, and that path is exactly what this guards.
|
|
4034
4134
|
*/
|
|
4035
4135
|
function resetJobFields(job, errorMsg, opts = {}) {
|
|
4036
4136
|
if ((job.status === 'completed' || job.status === 'skipped') && opts.force !== true) return false;
|
|
@@ -4090,12 +4190,51 @@ function resetJobFields(job, errorMsg, opts = {}) {
|
|
|
4090
4190
|
delete job.blockedByForeignWip;
|
|
4091
4191
|
delete job.foreignWipBlockedPaths;
|
|
4092
4192
|
delete job.foreignWipBlockCount;
|
|
4193
|
+
// A reset starts a new episode: whatever this row's last needs_review park
|
|
4194
|
+
// was about is over, so a later park must start its own hold clock from
|
|
4195
|
+
// scratch rather than inheriting a stale firstParkedAt from days earlier
|
|
4196
|
+
// (reviewNotice.cjs's own doc explains why that matters). The rung-6
|
|
4197
|
+
// ladder requeue (applyNeedsReviewAutoResolve) does NOT call
|
|
4198
|
+
// resetJobFields — it transitions the row directly — so that requeue
|
|
4199
|
+
// correctly keeps the same clock, because it is still the same episode.
|
|
4200
|
+
delete job.reviewNotice;
|
|
4201
|
+
// This run's completion evidence, not durable across a reset — an old
|
|
4202
|
+
// run's looksDone must never complete the NEXT run
|
|
4203
|
+
// (applyNeedsReviewAutoResolve completes on looksDone alone), and a stale
|
|
4204
|
+
// evidenceScannedAt must not block the next park's own scan.
|
|
4205
|
+
// computeLooksDone only counts commits since job.startedAt, so the next
|
|
4206
|
+
// park re-scans fresh once this run's own startedAt is set.
|
|
4207
|
+
delete job.looksDone;
|
|
4208
|
+
delete job.evidenceScannedAt;
|
|
4093
4209
|
// Deliberately NOT deleting job.landedCommit: it must outlive a reset so a
|
|
4094
4210
|
// re-fired run of this same slug can pass it to verifyRun as
|
|
4095
4211
|
// priorLandedCommit (pass_no_commit_prior_run_verified exemption).
|
|
4096
4212
|
return true;
|
|
4097
4213
|
}
|
|
4098
4214
|
|
|
4215
|
+
/**
|
|
4216
|
+
* Plain-word refusal message for a reset that resetJobFields' terminal-
|
|
4217
|
+
* status guard blocked. One branch per status that guard can refuse, plus a
|
|
4218
|
+
* generic fallback for any other status a caller might pass in (defensive —
|
|
4219
|
+
* resetJobFields today only refuses 'completed' and 'skipped').
|
|
4220
|
+
*
|
|
4221
|
+
* `canForce` tells the message whether force:true is actually available to
|
|
4222
|
+
* the caller: true for the admin/MCP resetJob (force threads through), false
|
|
4223
|
+
* for the renderer IPC schedule:reset-job (no force option there — see that
|
|
4224
|
+
* handler's own comment for why).
|
|
4225
|
+
*/
|
|
4226
|
+
function resetRefusalMessage(status, { canForce } = {}) {
|
|
4227
|
+
if (status === 'completed') {
|
|
4228
|
+
return `job already completed — resetting it would re-execute shipped work; archive the PRD instead${canForce ? ', or pass force:true' : ''}`;
|
|
4229
|
+
}
|
|
4230
|
+
if (status === 'skipped') {
|
|
4231
|
+
return canForce
|
|
4232
|
+
? 'job was skipped — pass force:true to run it again'
|
|
4233
|
+
: 'job was skipped — only scheduler_reset_job with force:true can run it again';
|
|
4234
|
+
}
|
|
4235
|
+
return `job status is "${status}" — it cannot be reset now`;
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4099
4238
|
/**
|
|
4100
4239
|
* partitionBootOrphans(jobs, liveness) → { immediate: string[], adopted: string[] }
|
|
4101
4240
|
*
|
|
@@ -4591,6 +4730,16 @@ async function notifyOriginatingTab(job, {
|
|
|
4591
4730
|
* non-active session and returns false. Nothing here can create an Epic — if
|
|
4592
4731
|
* no open authoring Epic exists, the root-cause report in the run directory
|
|
4593
4732
|
* is the whole record and the Scheduler tab is where it surfaces.
|
|
4733
|
+
*
|
|
4734
|
+
* Quiet by default (this PRD): the park path no longer calls this at park
|
|
4735
|
+
* time. It records a `reviewNotice` on the job row instead and lets
|
|
4736
|
+
* flushDueReviewNotices send one grouped message once the self-heal ladder
|
|
4737
|
+
* gives up or the hold time passes. This function now only runs under the
|
|
4738
|
+
* SM_REVIEW_NOTICE_IMMEDIATE=1 kill switch (same park-time call site, for
|
|
4739
|
+
* local debugging) — it is otherwise dead code outside its own tests. That
|
|
4740
|
+
* call site stamps `reviewNotice.sentAt` itself afterward, and only when
|
|
4741
|
+
* this returns true AND the row's live notice still matches the one it just
|
|
4742
|
+
* built — never on the strength of this function's return value alone.
|
|
4594
4743
|
*/
|
|
4595
4744
|
async function notifyNeedsReview(job, report, {
|
|
4596
4745
|
parsePrdRaw = prdParser.parsePrdRaw,
|
|
@@ -4621,6 +4770,133 @@ async function notifyNeedsReview(job, report, {
|
|
|
4621
4770
|
}
|
|
4622
4771
|
}
|
|
4623
4772
|
|
|
4773
|
+
// Single-flight guard for flushDueReviewNotices — same shape as
|
|
4774
|
+
// gateShadowPending above: a module-level flag rather than a class, so two
|
|
4775
|
+
// triggers landing close together (an auto-resolve skip right next to the
|
|
4776
|
+
// heartbeat interval) read-modify-write queue.json in series, never racing
|
|
4777
|
+
// each other over the same rows.
|
|
4778
|
+
let reviewNoticeFlushPending = null;
|
|
4779
|
+
|
|
4780
|
+
/**
|
|
4781
|
+
* flushDueReviewNotices() → Promise<void>
|
|
4782
|
+
*
|
|
4783
|
+
* The only path that still sends a needs_review notice (outside the
|
|
4784
|
+
* SM_REVIEW_NOTICE_IMMEDIATE=1 kill switch above notifyNeedsReview). Reads
|
|
4785
|
+
* the live queue, asks reviewNotice.cjs's selectDueReviewNotices which (cwd,
|
|
4786
|
+
* Epic, cause) groups have gone quiet — the ladder gave up
|
|
4787
|
+
* (needsReviewAutoResolvedSkip) or the hold time passed (holdMsFromEnv) —
|
|
4788
|
+
* and sends each group ONE message via formatReviewNotice, passing the
|
|
4789
|
+
* tick's own full job list so the message can name any `pending` row the
|
|
4790
|
+
* group still blocks.
|
|
4791
|
+
*
|
|
4792
|
+
* The default sender is appendResponseEventWithReason, which reports WHY a
|
|
4793
|
+
* send didn't land instead of collapsing every case to `false`. An injected
|
|
4794
|
+
* sender (tests) may still return a plain boolean — `true` is treated as ok,
|
|
4795
|
+
* `false` as a refusal with reason `'refused'` — and a sender that throws is
|
|
4796
|
+
* treated as reason `'error'`.
|
|
4797
|
+
*
|
|
4798
|
+
* What each outcome does to reviewNotice.sentAt, per row still at its
|
|
4799
|
+
* snapshotted (firstParkedAt, cause) and still needs_review/skipped live:
|
|
4800
|
+
* - ok, or a refusal with any reason OTHER than 'error': stamp sentAt now.
|
|
4801
|
+
* A refusal is logged (today it was silent) but is otherwise treated as
|
|
4802
|
+
* permanent — a deleted Epic or a completed session will never un-refuse
|
|
4803
|
+
* itself, so resending every tick forever would help no one.
|
|
4804
|
+
* - 'error' (an exception, e.g. a disk hiccup): do NOT stamp. Increment
|
|
4805
|
+
* reviewNotice.sendErrors instead, so the next pass retries. Once that
|
|
4806
|
+
* reaches 3, stamp sentAt anyway, set sendFailed: true, and log it —
|
|
4807
|
+
* retry a transient failure, but never loop on it forever.
|
|
4808
|
+
* A group with no resolved Epic/cwd never calls the sender at all — same
|
|
4809
|
+
* "report only" shape as before — and every row in it is stamped
|
|
4810
|
+
* unconditionally, same as a successful send.
|
|
4811
|
+
*
|
|
4812
|
+
* Single-flight via reviewNoticeFlushPending: a call while one is already in
|
|
4813
|
+
* flight returns the SAME promise instead of starting a second pass over the
|
|
4814
|
+
* same rows. Never throws — a bad read just leaves the notice for the next
|
|
4815
|
+
* pass to retry.
|
|
4816
|
+
*/
|
|
4817
|
+
async function flushDueReviewNotices({
|
|
4818
|
+
appendResponseEvent = appendResponseEventWithReason,
|
|
4819
|
+
now = Date.now(),
|
|
4820
|
+
} = {}) {
|
|
4821
|
+
if (reviewNoticeFlushPending) return reviewNoticeFlushPending;
|
|
4822
|
+
reviewNoticeFlushPending = (async () => {
|
|
4823
|
+
try {
|
|
4824
|
+
const state = await readQueue();
|
|
4825
|
+
if (state.unreadable) return;
|
|
4826
|
+
const groups = selectDueReviewNotices(state.jobs || [], { now, holdMs: holdMsFromEnv() });
|
|
4827
|
+
for (const group of groups) {
|
|
4828
|
+
const slugs = new Set(group.jobs.map((j) => j.slug));
|
|
4829
|
+
// Snapshot each row's own (firstParkedAt, cause) as it was when THIS
|
|
4830
|
+
// message was built — the mutate below only touches a row whose live
|
|
4831
|
+
// notice still matches its own snapshot, so a row that re-parked
|
|
4832
|
+
// with a new episode between this read and that mutate is left
|
|
4833
|
+
// alone rather than wrongly marked sent for a notice that never
|
|
4834
|
+
// described it.
|
|
4835
|
+
const snapshotBySlug = new Map(group.jobs.map((j) => [
|
|
4836
|
+
j.slug,
|
|
4837
|
+
{ firstParkedAt: j.reviewNotice?.firstParkedAt ?? null, cause: j.reviewNotice?.cause ?? null },
|
|
4838
|
+
]));
|
|
4839
|
+
const sentAt = new Date(now).toISOString();
|
|
4840
|
+
const stampIfLiveMatches = (s, apply) => {
|
|
4841
|
+
for (const j of s.jobs) {
|
|
4842
|
+
if (!slugs.has(j.slug) || !j.reviewNotice || j.reviewNotice.sentAt) continue;
|
|
4843
|
+
if (j.status !== 'needs_review' && j.status !== 'skipped') continue;
|
|
4844
|
+
const snap = snapshotBySlug.get(j.slug);
|
|
4845
|
+
if (j.reviewNotice.firstParkedAt !== snap.firstParkedAt || j.reviewNotice.cause !== snap.cause) continue;
|
|
4846
|
+
apply(j);
|
|
4847
|
+
}
|
|
4848
|
+
};
|
|
4849
|
+
|
|
4850
|
+
if (!group.epicId || !group.cwd) {
|
|
4851
|
+
console.log(`[scheduler] flushDueReviewNotices: no authoring Epic for ${group.jobs.map((j) => j.slug).join(', ')}, report only`);
|
|
4852
|
+
await mutate((s) => {
|
|
4853
|
+
stampIfLiveMatches(s, (j) => { j.reviewNotice.sentAt = sentAt; });
|
|
4854
|
+
}).catch(() => {});
|
|
4855
|
+
continue;
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
let outcome;
|
|
4859
|
+
try {
|
|
4860
|
+
const result = await appendResponseEvent(group.cwd, group.epicId, formatReviewNotice(group, { jobs: state.jobs }), {
|
|
4861
|
+
prdSlug: group.jobs[0].slug,
|
|
4862
|
+
outcome: 'needs_review',
|
|
4863
|
+
validation: 'unvalidated',
|
|
4864
|
+
});
|
|
4865
|
+
outcome = typeof result === 'boolean' ? { ok: result, reason: result ? undefined : 'refused' } : result;
|
|
4866
|
+
} catch (e) {
|
|
4867
|
+
console.error('[scheduler] flushDueReviewNotices appendResponseEvent error', group.epicId, e);
|
|
4868
|
+
outcome = { ok: false, reason: 'error' };
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
if (!outcome.ok && outcome.reason !== 'error') {
|
|
4872
|
+
console.warn(`[scheduler] flushDueReviewNotices: send refused (${outcome.reason}) for ${group.jobs.map((j) => j.slug).join(', ')}`);
|
|
4873
|
+
}
|
|
4874
|
+
|
|
4875
|
+
await mutate((s) => {
|
|
4876
|
+
stampIfLiveMatches(s, (j) => {
|
|
4877
|
+
if (outcome.ok || outcome.reason !== 'error') {
|
|
4878
|
+
j.reviewNotice.sentAt = sentAt;
|
|
4879
|
+
return;
|
|
4880
|
+
}
|
|
4881
|
+
const sendErrors = (j.reviewNotice.sendErrors ?? 0) + 1;
|
|
4882
|
+
j.reviewNotice.sendErrors = sendErrors;
|
|
4883
|
+
if (sendErrors >= 3) {
|
|
4884
|
+
j.reviewNotice.sentAt = sentAt;
|
|
4885
|
+
j.reviewNotice.sendFailed = true;
|
|
4886
|
+
console.error(`[scheduler] flushDueReviewNotices: giving up on ${j.slug} after ${sendErrors} send errors`);
|
|
4887
|
+
}
|
|
4888
|
+
});
|
|
4889
|
+
}).catch(() => {});
|
|
4890
|
+
}
|
|
4891
|
+
} catch (e) {
|
|
4892
|
+
console.error('[scheduler] flushDueReviewNotices error', e);
|
|
4893
|
+
} finally {
|
|
4894
|
+
reviewNoticeFlushPending = null;
|
|
4895
|
+
}
|
|
4896
|
+
})();
|
|
4897
|
+
return reviewNoticeFlushPending;
|
|
4898
|
+
}
|
|
4899
|
+
|
|
4624
4900
|
/** Scan the tail of a job's log for a network-outage signal: the structured
|
|
4625
4901
|
* `terminal_reason":"api_error"` field alongside a network-class error
|
|
4626
4902
|
* string. This is NOT a real code defect — spawning an auto-fix
|
|
@@ -5040,10 +5316,16 @@ function stampIntegrationFailure(row, integration) {
|
|
|
5040
5316
|
else delete row.integrationConflictPaths;
|
|
5041
5317
|
if (integration.baseHeadSha) row.integrationBaseHeadSha = integration.baseHeadSha;
|
|
5042
5318
|
else delete row.integrationBaseHeadSha;
|
|
5319
|
+
if (integration.expectedBranch) row.integrationExpectedBranch = integration.expectedBranch;
|
|
5320
|
+
else delete row.integrationExpectedBranch;
|
|
5321
|
+
if (integration.actualBranch) row.integrationActualBranch = integration.actualBranch;
|
|
5322
|
+
else delete row.integrationActualBranch;
|
|
5043
5323
|
} else {
|
|
5044
5324
|
delete row.integrationFailureKind;
|
|
5045
5325
|
delete row.integrationConflictPaths;
|
|
5046
5326
|
delete row.integrationBaseHeadSha;
|
|
5327
|
+
delete row.integrationExpectedBranch;
|
|
5328
|
+
delete row.integrationActualBranch;
|
|
5047
5329
|
}
|
|
5048
5330
|
}
|
|
5049
5331
|
|
|
@@ -5121,7 +5403,13 @@ function selectMechanicalRecoveryTarget(job, currentHeadSha = null) {
|
|
|
5121
5403
|
if (job.mechanicalRecoveryAttempted === true) return null;
|
|
5122
5404
|
if (isMechanicalRecoveryFutile(job, currentHeadSha)) return null;
|
|
5123
5405
|
const cwd = job.cwd || DEFAULT_PROJECT_CWD;
|
|
5124
|
-
return {
|
|
5406
|
+
return {
|
|
5407
|
+
slug: job.slug,
|
|
5408
|
+
cwd,
|
|
5409
|
+
branch: jobWorktree.branchNameFor(job.slug),
|
|
5410
|
+
carriedPaths: job.carriedPaths || [],
|
|
5411
|
+
baseBranch: job.worktreeBaseBranch || null,
|
|
5412
|
+
};
|
|
5125
5413
|
}
|
|
5126
5414
|
|
|
5127
5415
|
/**
|
|
@@ -5140,7 +5428,7 @@ function selectMechanicalRecoveryTarget(job, currentHeadSha = null) {
|
|
|
5140
5428
|
*/
|
|
5141
5429
|
async function performMechanicalRecovery(job, target) {
|
|
5142
5430
|
const integration = await jobWorktree.integrateJobBranch({
|
|
5143
|
-
cwd: target.cwd, branch: target.branch, slug: target.slug, carriedPaths: target.carriedPaths,
|
|
5431
|
+
cwd: target.cwd, branch: target.branch, slug: target.slug, carriedPaths: target.carriedPaths, baseBranch: target.baseBranch, allowRefLanding: true,
|
|
5144
5432
|
});
|
|
5145
5433
|
if (integration.ok) {
|
|
5146
5434
|
await jobWorktree.cleanupJobWorktree({ cwd: target.cwd, dir: undefined, branch: target.branch, keepBranch: false });
|
|
@@ -5151,6 +5439,7 @@ async function performMechanicalRecovery(job, target) {
|
|
|
5151
5439
|
if (!j) return;
|
|
5152
5440
|
j.mechanicalRecoveryAttempted = true;
|
|
5153
5441
|
if (integration.ok) {
|
|
5442
|
+
if (integration.viaRef) j.landedCommit = integration.sha;
|
|
5154
5443
|
if (transitionJob(j, 'completed', {
|
|
5155
5444
|
reason: `mechanical recovery: ${target.branch} re-integrated successfully`,
|
|
5156
5445
|
source: 'scheduler:mechanicalRecovery',
|
|
@@ -5166,6 +5455,9 @@ async function performMechanicalRecovery(job, target) {
|
|
|
5166
5455
|
}
|
|
5167
5456
|
});
|
|
5168
5457
|
if (integration.ok) {
|
|
5458
|
+
if (integration.viaRef) {
|
|
5459
|
+
console.log(`[scheduler] mechanical-recovery: ${job.slug} landed onto the base ref directly; the main checkout was busy on another branch and was never touched.`);
|
|
5460
|
+
}
|
|
5169
5461
|
console.log(`[scheduler] mechanical-recovery: ${job.slug} → completed (branch ${target.branch} re-integrated)`);
|
|
5170
5462
|
if (becameCompleted) await archiveCompletedPrd(job.slug, job.cwd);
|
|
5171
5463
|
} else {
|
|
@@ -5209,22 +5501,11 @@ function selectLeftoverQuarantineTarget(job) {
|
|
|
5209
5501
|
return { slug: job.slug, cwd: job.cwd, paths };
|
|
5210
5502
|
}
|
|
5211
5503
|
|
|
5504
|
+
// Delegates to the shared gitExec.execGit (gitExec.cjs has zero requires of
|
|
5505
|
+
// scheduler.cjs/gitWorktree.cjs, so no cycle) — semantics match: execFile
|
|
5506
|
+
// with an argv array, same timeout default, same env-merge-over-process.env.
|
|
5212
5507
|
function execGitAt(cwd, args, { env, timeout = 20_000 } = {}) {
|
|
5213
|
-
return
|
|
5214
|
-
execFile(
|
|
5215
|
-
'git',
|
|
5216
|
-
['-C', cwd, ...args],
|
|
5217
|
-
{ timeout, windowsHide: true, encoding: 'utf8', env: env ? { ...process.env, ...env } : process.env },
|
|
5218
|
-
(err, stdout, stderr) => {
|
|
5219
|
-
if (err) {
|
|
5220
|
-
err.stderrText = stderr;
|
|
5221
|
-
reject(err);
|
|
5222
|
-
return;
|
|
5223
|
-
}
|
|
5224
|
-
resolve(stdout || '');
|
|
5225
|
-
},
|
|
5226
|
-
);
|
|
5227
|
-
});
|
|
5508
|
+
return sharedGitExec.execGit(cwd, args, { timeout, env });
|
|
5228
5509
|
}
|
|
5229
5510
|
|
|
5230
5511
|
async function pathExistsInTree(cwd, treeish, p) {
|
|
@@ -5467,6 +5748,26 @@ async function performLeftoverQuarantine(job, paths, headBefore = null) {
|
|
|
5467
5748
|
});
|
|
5468
5749
|
}
|
|
5469
5750
|
|
|
5751
|
+
// A headless `claude -p` run has no later turn. A tool that waits for a
|
|
5752
|
+
// later turn (ScheduleWakeup, the Cron tools, Monitor) or for a human
|
|
5753
|
+
// (AskUserQuestion, plan mode) stalls the run: it ends with no commit and no
|
|
5754
|
+
// verdict, and the job parks in needs_review. EnterWorktree/ExitWorktree wait
|
|
5755
|
+
// on a worktree handoff that never comes back in a headless run either.
|
|
5756
|
+
// Agent and Skill stay allowed: the finish protocol runs /code-review and
|
|
5757
|
+
// /security-review, both of which dispatch sub-agents.
|
|
5758
|
+
const HEADLESS_DISALLOWED_TOOLS = Object.freeze([
|
|
5759
|
+
'ScheduleWakeup',
|
|
5760
|
+
'CronCreate',
|
|
5761
|
+
'CronDelete',
|
|
5762
|
+
'CronList',
|
|
5763
|
+
'Monitor',
|
|
5764
|
+
'AskUserQuestion',
|
|
5765
|
+
'EnterPlanMode',
|
|
5766
|
+
'ExitPlanMode',
|
|
5767
|
+
'EnterWorktree',
|
|
5768
|
+
'ExitWorktree',
|
|
5769
|
+
]);
|
|
5770
|
+
|
|
5470
5771
|
/**
|
|
5471
5772
|
* Pure argv builder for a `claude -p` child spawn, shared so the
|
|
5472
5773
|
* resume-vs-fresh-session choice is made in exactly one place. `resume`
|
|
@@ -5478,6 +5779,9 @@ async function performLeftoverQuarantine(job, paths, headBefore = null) {
|
|
|
5478
5779
|
* persona body, resolved by agentModelResolve.cjs's resolvePrdPersonaForSpawn),
|
|
5479
5780
|
* is passed as `--append-system-prompt` so the executor IS that persona at
|
|
5480
5781
|
* launch rather than being asked in prose to adopt one.
|
|
5782
|
+
* `--disallowedTools` takes a variadic list, so another flag must always
|
|
5783
|
+
* follow it to end that list — `--output-format` does, right after. Never
|
|
5784
|
+
* put `--disallowedTools` last or right before the prompt.
|
|
5481
5785
|
*/
|
|
5482
5786
|
function buildClaudeSpawnArgs({ prompt, model, effort, sessionId, resume, systemPrompt }) {
|
|
5483
5787
|
return [
|
|
@@ -5486,6 +5790,8 @@ function buildClaudeSpawnArgs({ prompt, model, effort, sessionId, resume, system
|
|
|
5486
5790
|
...effortArgs(effort),
|
|
5487
5791
|
...(systemPrompt ? ['--append-system-prompt', systemPrompt] : []),
|
|
5488
5792
|
'--dangerously-skip-permissions',
|
|
5793
|
+
...headlessPermissionArgs(),
|
|
5794
|
+
'--disallowedTools', HEADLESS_DISALLOWED_TOOLS.join(','),
|
|
5489
5795
|
'--output-format', 'stream-json',
|
|
5490
5796
|
'--verbose',
|
|
5491
5797
|
...(resume ? ['--resume', sessionId] : ['--session-id', sessionId]),
|
|
@@ -5508,6 +5814,36 @@ function pickRunDir() {
|
|
|
5508
5814
|
return { runId: ts, dir };
|
|
5509
5815
|
}
|
|
5510
5816
|
|
|
5817
|
+
// macOS ships no `timeout` command, but a PRD gate command starts with
|
|
5818
|
+
// `timeout <seconds> ...`. ensureTimeoutShimOnce() installs a dependency-free
|
|
5819
|
+
// stand-in (see lib/timeoutShim.cjs) once per process — not once per job — so
|
|
5820
|
+
// most executeJob calls reuse the same cached promise instead of re-checking
|
|
5821
|
+
// the files on disk. A failed install clears that cache, so the NEXT spawn
|
|
5822
|
+
// retries the install instead of running shim-less for the rest of the
|
|
5823
|
+
// process. It never throws and never blocks or fails a spawn: a failed
|
|
5824
|
+
// install just logs one line and leaves the PATH addition pointing at a shim
|
|
5825
|
+
// dir that may have nothing in it yet, no worse than today's
|
|
5826
|
+
// no-shim-at-all. SM_TIMEOUT_SHIM_DISABLE=1 skips it outright.
|
|
5827
|
+
let timeoutShimEnsured = null;
|
|
5828
|
+
function ensureTimeoutShimOnce() {
|
|
5829
|
+
if (process.env.SM_TIMEOUT_SHIM_DISABLE === '1') return Promise.resolve();
|
|
5830
|
+
if (!timeoutShimEnsured) {
|
|
5831
|
+
timeoutShimEnsured = ensureTimeoutShim().then(
|
|
5832
|
+
(result) => {
|
|
5833
|
+
if (!result.ok && !result.skipped) {
|
|
5834
|
+
console.error(`[scheduler] could not install the timeout shim: ${result.error}`);
|
|
5835
|
+
timeoutShimEnsured = null;
|
|
5836
|
+
}
|
|
5837
|
+
},
|
|
5838
|
+
(err) => {
|
|
5839
|
+
console.error(`[scheduler] could not install the timeout shim: ${err?.message ?? err}`);
|
|
5840
|
+
timeoutShimEnsured = null;
|
|
5841
|
+
},
|
|
5842
|
+
);
|
|
5843
|
+
}
|
|
5844
|
+
return timeoutShimEnsured;
|
|
5845
|
+
}
|
|
5846
|
+
|
|
5511
5847
|
/**
|
|
5512
5848
|
* Execute a single PRD job. Writes stdout/stderr to a log file and a meta
|
|
5513
5849
|
* JSON sidecar. Accepts an optional onPid(pid) callback called synchronously
|
|
@@ -5585,11 +5921,12 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
5585
5921
|
prompt = buildResumeRecoveryPreamble({ dirtyPaths: resumeTarget.dirtyPaths, salvagePatch: resumeTarget.salvagePatch });
|
|
5586
5922
|
} else {
|
|
5587
5923
|
// Read full PRD body fresh from disk (queue stored only the preview).
|
|
5588
|
-
// Resolve through
|
|
5589
|
-
// every project's Epic-scoped dirs
|
|
5590
|
-
//
|
|
5591
|
-
//
|
|
5592
|
-
|
|
5924
|
+
// Resolve through findPrdDirForJob's full candidate search (legacy flat
|
|
5925
|
+
// dir + every project's Epic-scoped dirs, then a stored absolute prd path,
|
|
5926
|
+
// then an ancestor-folder Epic walk above job.cwd) first, so the common
|
|
5927
|
+
// case — a live Epic-scoped PRD — is a first-try hit instead of probing
|
|
5928
|
+
// the retired flat dir and only then falling back.
|
|
5929
|
+
const resolvedDir = await findPrdDirForJob(job);
|
|
5593
5930
|
prdPath = resolvedDir ? path.join(resolvedDir, `${job.slug}.md`) : prdPathForJob(job);
|
|
5594
5931
|
try {
|
|
5595
5932
|
const parsed = await parsePrd(prdPath);
|
|
@@ -5603,8 +5940,8 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
5603
5940
|
// The project-scoped dir isn't the only place a PRD source can live — a
|
|
5604
5941
|
// writer that hasn't migrated to prdLocations.cjs yet (or a not-yet-run
|
|
5605
5942
|
// boot migration) can leave it in the legacy global dir. Fall back to
|
|
5606
|
-
//
|
|
5607
|
-
const fallbackDir = await
|
|
5943
|
+
// findPrdDirForJob's full candidate search before failing the job outright.
|
|
5944
|
+
const fallbackDir = await findPrdDirForJob(job);
|
|
5608
5945
|
if (fallbackDir) {
|
|
5609
5946
|
const fallbackPath = path.join(fallbackDir, `${job.slug}.md`);
|
|
5610
5947
|
safeLog(`[scheduler] PRD not in project dir; found ${job.slug}.md in ${fallbackDir}\n`);
|
|
@@ -5739,6 +6076,13 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
5739
6076
|
const personaEffort = resolveEpicEffort({ cwd, agentType: job.agentType }).effort;
|
|
5740
6077
|
safeLog(`[scheduler] agentType=${job.agentType || '(none)'} persona=${personaResolution.personaPath || '(fallback — no persona applied)'} model=${personaResolution.model}${personaEffort ? ` effort=${personaEffort}` : ''}\n`);
|
|
5741
6078
|
|
|
6079
|
+
// Authoritative guarantee that buildClaudeSpawnArgs (a pure sync helper,
|
|
6080
|
+
// called below via withChildAndLog) sees a resolved --permission-prompts
|
|
6081
|
+
// probe result — the module-load priming above is a warm-cache head start,
|
|
6082
|
+
// not a correctness guarantee on its own.
|
|
6083
|
+
await ensureCliCapsProbed();
|
|
6084
|
+
await ensureTimeoutShimOnce();
|
|
6085
|
+
|
|
5742
6086
|
return await new Promise((resolve) => {
|
|
5743
6087
|
const claudeBin = resolveClaudeBin();
|
|
5744
6088
|
// Strip Claude Code env and secrets that leak in when session-manager is
|
|
@@ -5759,8 +6103,11 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
5759
6103
|
// `launchEnv` is the launch circuit breaker's degraded-mode env (e.g.
|
|
5760
6104
|
// MAX_THINKING_TOKENS=0 while an outdated CLI's thinking parameter is
|
|
5761
6105
|
// being rejected — lib/launchFailure.cjs); applied last so it wins.
|
|
6106
|
+
// SM_TIMEOUT_SHIM_DISABLE=1 is the kill switch for the PATH addition
|
|
6107
|
+
// below — paired with the install skip inside ensureTimeoutShimOnce().
|
|
6108
|
+
const timeoutShimEnabled = process.env.SM_TIMEOUT_SHIM_DISABLE !== '1';
|
|
5762
6109
|
const childEnv = cleanChildEnv({
|
|
5763
|
-
PATH: pathWithUserBins(),
|
|
6110
|
+
PATH: timeoutShimEnabled ? withTimeoutShimOnPath(pathWithUserBins()) : pathWithUserBins(),
|
|
5764
6111
|
SM_PROJECT_ROOT: cwd,
|
|
5765
6112
|
SM_SCHEDULER_JOB_SLUG: job.slug,
|
|
5766
6113
|
SM_SCHEDULER_JOB_MAY_QUEUE: job.agentType === 'architect' ? '1' : '0',
|
|
@@ -5768,6 +6115,13 @@ async function executeJob(job, runDir, defaultCwd, onPid, execCwd, resumeTarget
|
|
|
5768
6115
|
BASH_MAX_TIMEOUT_MS: String(BASH_MAX_TIMEOUT_MS),
|
|
5769
6116
|
...(launchEnv && typeof launchEnv === 'object' ? launchEnv : {}),
|
|
5770
6117
|
});
|
|
6118
|
+
// Set AFTER cleanChildEnv returns, never inside the object passed to it:
|
|
6119
|
+
// cleanChildEnv strips every CLAUDE_CODE_*-prefixed key from its own
|
|
6120
|
+
// merged result (see cleanEnv.cjs), so setting it there would delete its
|
|
6121
|
+
// own addition. A headless run has no later turn, so a background task
|
|
6122
|
+
// it starts has nothing to report back to — same stall as a tool that
|
|
6123
|
+
// waits for one.
|
|
6124
|
+
childEnv.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS = '1';
|
|
5771
6125
|
if (launchEnv && Object.keys(launchEnv).length) {
|
|
5772
6126
|
safeLog(`[scheduler] launch mitigation env applied: ${Object.entries(launchEnv).map(([k, v]) => `${k}=${v}`).join(' ')}\n`);
|
|
5773
6127
|
}
|
|
@@ -6339,6 +6693,14 @@ async function spawnInvestigation(failedJob, runDir, { deadChild = null } = {})
|
|
|
6339
6693
|
console.log(`[scheduler] skip investigation: ${failedJob.slug} is mechanical-recovery eligible`);
|
|
6340
6694
|
return { deferred: false };
|
|
6341
6695
|
}
|
|
6696
|
+
// stray_checkout is environmental (the project's own checkout sits off the
|
|
6697
|
+
// expected base branch) — no fix-plan PRD can ever land against it; a human
|
|
6698
|
+
// must fix the checkout. Authoring one anyway just re-hits the same refusal
|
|
6699
|
+
// on every retry, forever blocking dependents.
|
|
6700
|
+
if (failedJob.integrationFailureKind === 'stray_checkout') {
|
|
6701
|
+
console.log(`[scheduler] skip investigation: ${failedJob.slug} integration blocked by stray checkout (environmental)`);
|
|
6702
|
+
return { deferred: false };
|
|
6703
|
+
}
|
|
6342
6704
|
if (isFixPlanBeyondDepthCap(failedJob.slug, failedJob.investigationDepth, failedJob.isFixPlan)) {
|
|
6343
6705
|
console.log(`[scheduler] skip investigation: ${failedJob.slug} is a fix plan at/beyond depth cap (depth=${failedJob.investigationDepth ?? 'none'})`);
|
|
6344
6706
|
return { deferred: false };
|
|
@@ -6448,18 +6810,51 @@ async function spawnInvestigation(failedJob, runDir, { deadChild = null } = {})
|
|
|
6448
6810
|
// the whole probe duration — previously this left the job's persisted
|
|
6449
6811
|
// status frozen at 'failed'/'needs_review' the entire time, which read as
|
|
6450
6812
|
// "nothing is happening" even though an Opus process was actively running.
|
|
6813
|
+
//
|
|
6814
|
+
// Live-row check, in the SAME mutate: `failedJob` can be a stale snapshot
|
|
6815
|
+
// (the deferred-investigation path keeps one from the moment the slot was
|
|
6816
|
+
// busy) and the row underneath it can have moved on — most commonly the
|
|
6817
|
+
// background gate shadow completed it in this same pass. Re-check the row
|
|
6818
|
+
// actually read here, not the snapshot the caller passed in: it must
|
|
6819
|
+
// exist, its live status must still be 'needs_review' or 'failed', and
|
|
6820
|
+
// transitionJob must accept the move. Fail closed on any of the three —
|
|
6821
|
+
// never spawn a probe for a row that is already finished or gone.
|
|
6822
|
+
let liveCheck = { ok: false, status: 'gone' };
|
|
6451
6823
|
await mutate((s) => {
|
|
6452
6824
|
const j = s.jobs.find((x) => x.slug === failedJob.slug);
|
|
6453
|
-
if (j)
|
|
6825
|
+
if (!j) return;
|
|
6826
|
+
const statusEligible = j.status === 'needs_review' || j.status === 'failed';
|
|
6827
|
+
const transitioned = transitionJob(j, 'investigating', { reason: 'spawning investigation probe', source: 'spawnInvestigation:start' });
|
|
6828
|
+
liveCheck = { ok: statusEligible && transitioned, status: j.status };
|
|
6454
6829
|
});
|
|
6830
|
+
if (!liveCheck.ok) {
|
|
6831
|
+
const msg = `[scheduler] skip investigation: ${failedJob.slug} is now ${liveCheck.status} — not probing`;
|
|
6832
|
+
console.log(msg);
|
|
6833
|
+
safeLog(`${msg}\n`);
|
|
6834
|
+
closeFd();
|
|
6835
|
+
releaseSlot();
|
|
6836
|
+
return { deferred: false };
|
|
6837
|
+
}
|
|
6455
6838
|
await broadcast({ flush: true });
|
|
6456
6839
|
|
|
6840
|
+
await ensureCliCapsProbed();
|
|
6841
|
+
await ensureTimeoutShimOnce();
|
|
6457
6842
|
const claudeBin = resolveClaudeBin();
|
|
6843
|
+
// SM_TIMEOUT_SHIM_DISABLE=1 is the kill switch for the PATH addition below —
|
|
6844
|
+
// paired with the install skip inside ensureTimeoutShimOnce().
|
|
6845
|
+
const timeoutShimEnabled = process.env.SM_TIMEOUT_SHIM_DISABLE !== '1';
|
|
6458
6846
|
const childEnv = cleanChildEnv({
|
|
6459
|
-
PATH: pathWithUserBins(), // Homebrew/user bins for macOS
|
|
6847
|
+
PATH: timeoutShimEnabled ? withTimeoutShimOnPath(pathWithUserBins()) : pathWithUserBins(), // Homebrew/user bins for macOS
|
|
6460
6848
|
BASH_DEFAULT_TIMEOUT_MS: String(BASH_DEFAULT_TIMEOUT_MS),
|
|
6461
6849
|
BASH_MAX_TIMEOUT_MS: String(BASH_MAX_TIMEOUT_MS),
|
|
6462
6850
|
});
|
|
6851
|
+
// Set AFTER cleanChildEnv returns, never inside the object passed to it:
|
|
6852
|
+
// cleanChildEnv strips every CLAUDE_CODE_*-prefixed key from its own merged
|
|
6853
|
+
// result (see cleanEnv.cjs), so setting it there would delete its own
|
|
6854
|
+
// addition. A headless run has no later turn, so a background task it
|
|
6855
|
+
// starts has nothing to report back to — same stall as a tool that waits
|
|
6856
|
+
// for one.
|
|
6857
|
+
childEnv.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS = '1';
|
|
6463
6858
|
|
|
6464
6859
|
// Investigation needs only a deadman watchdog — no idle-tail or result-tail
|
|
6465
6860
|
// since investigations are short-running Opus probes with a hard ceiling.
|
|
@@ -6487,6 +6882,8 @@ async function spawnInvestigation(failedJob, runDir, { deadChild = null } = {})
|
|
|
6487
6882
|
'-p', prompt,
|
|
6488
6883
|
'--model', 'opus',
|
|
6489
6884
|
'--dangerously-skip-permissions',
|
|
6885
|
+
...headlessPermissionArgs(),
|
|
6886
|
+
'--disallowedTools', HEADLESS_DISALLOWED_TOOLS.join(','),
|
|
6490
6887
|
'--output-format', 'stream-json',
|
|
6491
6888
|
'--verbose',
|
|
6492
6889
|
'--session-id', sessionId,
|
|
@@ -6867,7 +7264,7 @@ async function finalizeJobWorktree({ job, runDir, worktree, guardCwd, carriedPat
|
|
|
6867
7264
|
console.log(`[scheduler] ${job.slug}: salvaged ${salvage.bytes} byte(s) of uncommitted worktree diff to ${salvagePath}`);
|
|
6868
7265
|
}
|
|
6869
7266
|
}
|
|
6870
|
-
const integration = await jw.integrateJobBranch({ cwd: guardCwd, branch: worktree.branch, slug: job.slug, carriedPaths });
|
|
7267
|
+
const integration = await jw.integrateJobBranch({ cwd: guardCwd, branch: worktree.branch, slug: job.slug, carriedPaths, baseBranch: worktree.baseBranch });
|
|
6871
7268
|
if (integration.ok && integration.reason === 'carried-wip-only') {
|
|
6872
7269
|
console.log(`[scheduler] ${job.slug}: worktree branch ${worktree.branch} touched only carried base WIP paths — skipping merge (carried-wip-only)`);
|
|
6873
7270
|
}
|
|
@@ -7115,6 +7512,14 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
|
|
|
7115
7512
|
source: 'spawnJob:dispatch',
|
|
7116
7513
|
});
|
|
7117
7514
|
delete s.jobs[idx].heldReason;
|
|
7515
|
+
// A fresh dispatch means any gateShadow, looksDone, or
|
|
7516
|
+
// evidenceScannedAt on this row was computed for an earlier
|
|
7517
|
+
// runId/startedAt — never let any of them linger and read as this
|
|
7518
|
+
// run's result (looksDone stale here would let rung 6 complete this
|
|
7519
|
+
// run on the PREVIOUS run's evidence).
|
|
7520
|
+
delete s.jobs[idx].gateShadow;
|
|
7521
|
+
delete s.jobs[idx].looksDone;
|
|
7522
|
+
delete s.jobs[idx].evidenceScannedAt;
|
|
7118
7523
|
s.jobs[idx].runId = runId;
|
|
7119
7524
|
s.jobs[idx].startedAt = new Date().toISOString();
|
|
7120
7525
|
// Dispatch-phase breadcrumb (PRD: dispatch-region diagnostic
|
|
@@ -7246,12 +7651,18 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
|
|
|
7246
7651
|
// recorded on the job row so integration can exclude these paths from
|
|
7247
7652
|
// the branch diff below, and so it's queryable from the queue.
|
|
7248
7653
|
const carriedPaths = (worktree.ok && Array.isArray(worktree.carriedPaths)) ? worktree.carriedPaths : [];
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7654
|
+
// baseBranch (createWorktree, this PRD) — the branch `cwd` was actually on
|
|
7655
|
+
// when the worktree forked, threaded onto the job row so a later mechanical
|
|
7656
|
+
// recovery retries integration against the SAME target, not whatever `cwd`
|
|
7657
|
+
// happens to sit on by then.
|
|
7658
|
+
const worktreeBaseBranch = (worktree.ok && typeof worktree.baseBranch === 'string' && worktree.baseBranch) ? worktree.baseBranch : null;
|
|
7659
|
+
await mutate((s) => {
|
|
7660
|
+
const idx = s.jobs.findIndex((x) => x.slug === job.slug);
|
|
7661
|
+
if (idx < 0) return;
|
|
7662
|
+
if (carriedPaths.length) s.jobs[idx].carriedPaths = carriedPaths;
|
|
7663
|
+
if (worktreeBaseBranch) s.jobs[idx].worktreeBaseBranch = worktreeBaseBranch;
|
|
7664
|
+
else delete s.jobs[idx].worktreeBaseBranch;
|
|
7665
|
+
});
|
|
7255
7666
|
|
|
7256
7667
|
// Integrate the job's branch back into guardCwd's own HEAD, THEN tear the
|
|
7257
7668
|
// worktree checkout down — both must happen BEFORE any git read below
|
|
@@ -8138,14 +8549,56 @@ async function spawnJob(job, runId, runDir, defaultCwd, resumeTarget = null, sib
|
|
|
8138
8549
|
annotations: needsReviewRcaSnapshot.verifierAnnotations,
|
|
8139
8550
|
})
|
|
8140
8551
|
.then(async (report) => {
|
|
8141
|
-
//
|
|
8142
|
-
//
|
|
8143
|
-
//
|
|
8552
|
+
// Resolve the authoring Epic the same way notifyNeedsReview does —
|
|
8553
|
+
// the review notice needs the same epicId so flushDueReviewNotices
|
|
8554
|
+
// can route the eventual grouped message to the right place.
|
|
8555
|
+
let epicId = null;
|
|
8556
|
+
try {
|
|
8557
|
+
const prd = await resolveNotifyPrd(needsReviewRcaSnapshot, prdParser.parsePrdRaw);
|
|
8558
|
+
epicId = prd?.sourcePromptId || needsReviewRcaSnapshot.epicId || null;
|
|
8559
|
+
} catch {
|
|
8560
|
+
epicId = null;
|
|
8561
|
+
}
|
|
8562
|
+
// Persist the RCA classification AND the review notice onto the
|
|
8563
|
+
// parked job row in the SAME mutate — the scheduler routes on the
|
|
8564
|
+
// classification (e.g. selectAutoFixTargets excluding 'archive')
|
|
8565
|
+
// and flushDueReviewNotices routes on the notice, neither re-parsing
|
|
8566
|
+
// the RCA markdown nor losing the notice to a lost race.
|
|
8567
|
+
let builtNotice = null;
|
|
8144
8568
|
await mutate((s) => {
|
|
8145
8569
|
const j = s.jobs.find((x) => x.slug === needsReviewRcaSnapshot.slug);
|
|
8146
8570
|
applyRcaClassification(j, report);
|
|
8147
|
-
|
|
8148
|
-
|
|
8571
|
+
// Write the notice only while the row is still the SAME
|
|
8572
|
+
// needs_review episode this RCA was filed for — a row a human
|
|
8573
|
+
// already reset, or that moved on before this async write
|
|
8574
|
+
// lands, must not get a stale notice grafted back onto it.
|
|
8575
|
+
if (j && j.status === 'needs_review') {
|
|
8576
|
+
j.reviewNotice = buildReviewNotice({ job: j, report, epicId, now: new Date().toISOString(), prior: j.reviewNotice });
|
|
8577
|
+
builtNotice = j.reviewNotice;
|
|
8578
|
+
}
|
|
8579
|
+
}).catch((e) => {
|
|
8580
|
+
console.error('[scheduler] writeRcaReport reviewNotice mutate error', needsReviewRcaSnapshot.slug, e);
|
|
8581
|
+
});
|
|
8582
|
+
// Quiet by default (this PRD): the notice recorded above waits for
|
|
8583
|
+
// flushDueReviewNotices to send it, grouped, once the ladder gives
|
|
8584
|
+
// up or the hold time passes. SM_REVIEW_NOTICE_IMMEDIATE=1 restores
|
|
8585
|
+
// the old immediate-notify behavior, for local debugging.
|
|
8586
|
+
if (process.env.SM_REVIEW_NOTICE_IMMEDIATE === '1') {
|
|
8587
|
+
const sent = await notifyNeedsReview(needsReviewRcaSnapshot, report);
|
|
8588
|
+
if (sent && builtNotice) {
|
|
8589
|
+
await mutate((s) => {
|
|
8590
|
+
const j = s.jobs.find((x) => x.slug === needsReviewRcaSnapshot.slug);
|
|
8591
|
+
// Stamp only if the live notice is still the exact one just
|
|
8592
|
+
// sent — a re-park between the send and this mutate must not
|
|
8593
|
+
// be marked sent for a notice that never went out.
|
|
8594
|
+
if (j && j.reviewNotice && j.reviewNotice.firstParkedAt === builtNotice.firstParkedAt && j.reviewNotice.cause === builtNotice.cause) {
|
|
8595
|
+
j.reviewNotice.sentAt = new Date().toISOString();
|
|
8596
|
+
}
|
|
8597
|
+
}).catch((e) => {
|
|
8598
|
+
console.error('[scheduler] writeRcaReport immediate-stamp mutate error', needsReviewRcaSnapshot.slug, e);
|
|
8599
|
+
});
|
|
8600
|
+
}
|
|
8601
|
+
}
|
|
8149
8602
|
})
|
|
8150
8603
|
.catch((e) => {
|
|
8151
8604
|
console.error('[scheduler] writeRcaReport error', job.slug, e);
|
|
@@ -8527,7 +8980,13 @@ async function tickBody(gen, { bypassLoadGate }) {
|
|
|
8527
8980
|
if (batch.length === 0) {
|
|
8528
8981
|
// Queue drained — run the definition-of-done gate fire-and-forget.
|
|
8529
8982
|
// Non-blocking: does not hold the mutate lock; errors are logged, not thrown.
|
|
8530
|
-
|
|
8983
|
+
// resolvePrdPath mirrors every other verify call site in this file (e.g.
|
|
8984
|
+
// computeLooksDone, runGateShadow): try the live Epic-scoped dir first,
|
|
8985
|
+
// then the archived twin — never the retired flat dir.
|
|
8986
|
+
runDefinitionOfDoneOnDrain(state, {
|
|
8987
|
+
cancelToken,
|
|
8988
|
+
resolvePrdPath: async (job) => (await resolveVerifyPrdPath(job)) ?? archivedPrdPathForJob(job),
|
|
8989
|
+
}).catch((err) => {
|
|
8531
8990
|
console.log(`[scheduler] dod-drain: ${err?.message ?? String(err)}`);
|
|
8532
8991
|
});
|
|
8533
8992
|
if (holdReason) return recordTick({ fired: false, reason: 'held', detail: holdReason }, { holds });
|
|
@@ -10922,22 +11381,58 @@ async function computeLooksDone(job, fetchedCwds) {
|
|
|
10922
11381
|
return { commits: attributed.commits, paths, detectedAt: new Date().toISOString(), rule: attributed.rule };
|
|
10923
11382
|
}
|
|
10924
11383
|
|
|
11384
|
+
// Dirty, non-ignored tracked+untracked paths at `cwd`, app-owned churn
|
|
11385
|
+
// stripped (stripAppOwnedChurn) — null on any git failure, never thrown.
|
|
11386
|
+
// runGateShadow calls this once before the gate and once after, so it can
|
|
11387
|
+
// tell a tree that was clean throughout from one a foreign write (or the
|
|
11388
|
+
// gate command itself) touched mid-gate. Deliberately no
|
|
11389
|
+
// `--untracked-files=no`: an uncommitted new file left by a shared-tree job
|
|
11390
|
+
// must count as dirty here (see decideGateAuthority's cleanOk).
|
|
11391
|
+
async function gateTreeDirt(cwd) {
|
|
11392
|
+
try {
|
|
11393
|
+
return stripAppOwnedChurn(parsePorcelain(await execGitAt(cwd, ['status', '--porcelain'])));
|
|
11394
|
+
} catch {
|
|
11395
|
+
return null;
|
|
11396
|
+
}
|
|
11397
|
+
}
|
|
11398
|
+
|
|
10925
11399
|
/**
|
|
10926
|
-
* Shadow gate
|
|
10927
|
-
*
|
|
10928
|
-
*
|
|
10929
|
-
*
|
|
10930
|
-
*
|
|
10931
|
-
*
|
|
10932
|
-
*
|
|
11400
|
+
* Shadow gate: run a needs_review row's authored gate at the project's
|
|
11401
|
+
* current HEAD and record what it decided as `gateShadow`, on the verdicts
|
|
11402
|
+
* sidecar and the row. For every verdict this is observation only — status
|
|
11403
|
+
* never changes from it alone. For the three verdicts in
|
|
11404
|
+
* GATE_AUTHORITY_VERDICTS, with a landed commit that is git-verified evidence
|
|
11405
|
+
* from THIS dispatch (never an older run's stale sha — see
|
|
11406
|
+
* resolveLandedCommitEvidence) that is also an ancestor of HEAD recorded
|
|
11407
|
+
* before the gate ran, a green re-run against that EXACT tree (HEAD unmoved,
|
|
11408
|
+
* tracked+untracked tree clean, both before and after the gate ran) is gate
|
|
11409
|
+
* AUTHORITY: it completes the row with no human in the loop. Any other
|
|
11410
|
+
* result — stale/missing evidence, the commit not an ancestor, a dirty tree,
|
|
11411
|
+
* HEAD moving mid-gate, no gate, or a non-green outcome — leaves status
|
|
11412
|
+
* untouched.
|
|
11413
|
+
*
|
|
11414
|
+
* Takes no slot (not a claude -p run — runGateSequence keeps one shadow gate
|
|
11415
|
+
* in flight machine-wide). Never called from finalize: only the reverify
|
|
11416
|
+
* pass. `gateShadow.definitive` marks whether this result is settled: true
|
|
11417
|
+
* only for a clean-throughout, HEAD-unmoved run with a real pass/fail outcome
|
|
11418
|
+
* — the only shape worth trusting indefinitely. Anything else (a dirty tree,
|
|
11419
|
+
* a moved HEAD, or an unavailable outcome) is recorded but re-run on the very
|
|
11420
|
+
* next pick instead of being memoized as settled.
|
|
11421
|
+
*
|
|
11422
|
+
* Returns the recorded gateShadow, or null when nothing was recorded (HEAD
|
|
11423
|
+
* unreadable — there is no tree to bind the result to —, already recorded
|
|
11424
|
+
* definitively at this HEAD, PRD unreadable, or another shadow gate is
|
|
11425
|
+
* running).
|
|
10933
11426
|
*/
|
|
10934
11427
|
async function runGateShadow(job) {
|
|
10935
11428
|
if (!job || !job.slug || !job.cwd) return null;
|
|
10936
11429
|
const prdPath = (await resolveVerifyPrdPath(job)) ?? archivedPrdPathForJob(job);
|
|
10937
11430
|
let prdText;
|
|
10938
11431
|
try { prdText = fs.readFileSync(prdPath, 'utf8'); } catch { return null; }
|
|
10939
|
-
const
|
|
10940
|
-
if (
|
|
11432
|
+
const headBefore = await gitHead(job.cwd);
|
|
11433
|
+
if (!headBefore) return null; // no tree to bind the result to
|
|
11434
|
+
if (job.gateShadow && job.gateShadow.head === headBefore && job.gateShadow.definitive === true) return null;
|
|
11435
|
+
const dirtBefore = await gateTreeDirt(job.cwd);
|
|
10941
11436
|
const gate = resolveGate(prdText);
|
|
10942
11437
|
let outcome;
|
|
10943
11438
|
if (gate.source === 'none') outcome = { status: 'unavailable', reason: 'gate-opt-out', results: [] };
|
|
@@ -10947,7 +11442,48 @@ async function runGateShadow(job) {
|
|
|
10947
11442
|
if (r.status === 'busy') return null;
|
|
10948
11443
|
outcome = r;
|
|
10949
11444
|
}
|
|
10950
|
-
const
|
|
11445
|
+
const headAfter = await gitHead(job.cwd);
|
|
11446
|
+
const dirtAfter = await gateTreeDirt(job.cwd);
|
|
11447
|
+
const headStable = headAfter !== null && headAfter === headBefore;
|
|
11448
|
+
const cleanOk = Array.isArray(dirtBefore) && dirtBefore.length === 0
|
|
11449
|
+
&& Array.isArray(dirtAfter) && dirtAfter.length === 0;
|
|
11450
|
+
// "A real pass or fail" — an unavailable outcome (opt-out, no parseable
|
|
11451
|
+
// gate) is never definitive even at a clean, unmoved tree: it says nothing
|
|
11452
|
+
// about done-ness, so it must stay eligible for retry once the PRD gains a
|
|
11453
|
+
// real gate. `busy` never reaches here (returns early above).
|
|
11454
|
+
const definitive = headStable && cleanOk && (outcome.status === 'green' || outcome.status === 'red');
|
|
11455
|
+
const gateShadow = { ...outcome, head: headBefore, source: gate.source, ranAt: new Date().toISOString(), definitive };
|
|
11456
|
+
|
|
11457
|
+
// Gate authority: a green re-run may complete the row outright, but only
|
|
11458
|
+
// for transcript-noise verdicts with a landed commit — every other park
|
|
11459
|
+
// (including no verdict at all) falls through to the plain observe-only
|
|
11460
|
+
// path above, unchanged. evidenceOk/ancestorOk/cleanOk/headStable are all
|
|
11461
|
+
// ground-truth git checks against job.startedAt and headBefore — never
|
|
11462
|
+
// trusted from anything the job itself reported.
|
|
11463
|
+
let decision = null;
|
|
11464
|
+
if (
|
|
11465
|
+
job.verifierVerdict
|
|
11466
|
+
&& GATE_AUTHORITY_VERDICTS.includes(job.verifierVerdict)
|
|
11467
|
+
&& typeof job.landedCommit === 'string'
|
|
11468
|
+
&& job.landedCommit.length > 0
|
|
11469
|
+
) {
|
|
11470
|
+
// resolveLandedCommitEvidence skips its own time check when sinceIso is
|
|
11471
|
+
// empty — so a missing/unparseable startedAt must refuse here, never
|
|
11472
|
+
// fall through to "no time bound at all".
|
|
11473
|
+
const startedAtMs = typeof job.startedAt === 'string' ? Date.parse(job.startedAt) : NaN;
|
|
11474
|
+
let evidenceOk = false;
|
|
11475
|
+
if (Number.isFinite(startedAtMs)) {
|
|
11476
|
+
try { evidenceOk = await resolveLandedCommitEvidence(job.cwd, job.landedCommit, job.startedAt); } catch { evidenceOk = false; }
|
|
11477
|
+
}
|
|
11478
|
+
let ancestorOk = false;
|
|
11479
|
+
try {
|
|
11480
|
+
await execGitAt(job.cwd, ['merge-base', '--is-ancestor', job.landedCommit, headBefore], { timeout: 10_000 });
|
|
11481
|
+
ancestorOk = true;
|
|
11482
|
+
} catch { ancestorOk = false; }
|
|
11483
|
+
decision = decideGateAuthority({ job, gate, outcome, evidenceOk, ancestorOk, cleanOk, headStable });
|
|
11484
|
+
gateShadow.authority = decision;
|
|
11485
|
+
}
|
|
11486
|
+
|
|
10951
11487
|
const runId = job.runId || resolveRunId(job);
|
|
10952
11488
|
if (runId) {
|
|
10953
11489
|
const verdictsPath = path.join(schedulerPaths.runsDir(), runId, `${job.slug}.verdicts.json`);
|
|
@@ -10959,17 +11495,121 @@ async function runGateShadow(job) {
|
|
|
10959
11495
|
try { atomicWriteJsonSync(verdictsPath, { ...existing, gateShadow }); } catch { /* best-effort */ }
|
|
10960
11496
|
}
|
|
10961
11497
|
}
|
|
11498
|
+
|
|
11499
|
+
const headShort = headBefore ? headBefore.slice(0, 7) : 'unknown';
|
|
11500
|
+
|
|
11501
|
+
// Resolve inside the SAME mutate that records the shadow, re-checking the
|
|
11502
|
+
// live row still matches the snapshot `job` on status, verdict, runId AND
|
|
11503
|
+
// startedAt — a park can change underneath this async function (heal,
|
|
11504
|
+
// human reset, a fresh dispatch of the same slug) between the gate run and
|
|
11505
|
+
// this write landing, and any such change invalidates both the plain
|
|
11506
|
+
// observation and the authority decision alike, so the whole result is
|
|
11507
|
+
// dropped rather than written onto a row it was never computed for.
|
|
11508
|
+
let resolved = null;
|
|
10962
11509
|
await mutate((s) => {
|
|
10963
11510
|
for (const j of s.jobs) {
|
|
10964
|
-
if (j.slug
|
|
11511
|
+
if (j.slug !== job.slug) continue;
|
|
11512
|
+
const unchanged = j.status === 'needs_review'
|
|
11513
|
+
&& j.verifierVerdict === job.verifierVerdict
|
|
11514
|
+
&& j.runId === job.runId
|
|
11515
|
+
&& j.startedAt === job.startedAt;
|
|
11516
|
+
if (!unchanged) {
|
|
11517
|
+
console.log(`[scheduler] gate authority: ${job.slug} result dropped — row changed under the gate`);
|
|
11518
|
+
continue;
|
|
11519
|
+
}
|
|
11520
|
+
j.gateShadow = gateShadow;
|
|
11521
|
+
if (!decision?.complete) continue;
|
|
11522
|
+
const v = j.verifierVerdict;
|
|
11523
|
+
transitionJob(j, 'completed', {
|
|
11524
|
+
reason: `gate re-run green at ${headShort}; verdict ${v} overridden`,
|
|
11525
|
+
source: 'gateAuthoritative',
|
|
11526
|
+
});
|
|
11527
|
+
j.error = null;
|
|
11528
|
+
delete j.verifierVerdict;
|
|
11529
|
+
delete j.looksDone;
|
|
11530
|
+
resolved = { slug: j.slug, cwd: j.cwd, verdict: v };
|
|
10965
11531
|
}
|
|
10966
11532
|
});
|
|
11533
|
+
if (resolved) {
|
|
11534
|
+
try { await archiveCompletedPrd(resolved.slug, resolved.cwd); } catch (e) {
|
|
11535
|
+
console.error('[scheduler] gate authority archive error', resolved.slug, e);
|
|
11536
|
+
}
|
|
11537
|
+
appendAuditEvent('needs_review_gate_resolved', { slug: resolved.slug, cwd: resolved.cwd, verdict: resolved.verdict, head: headBefore });
|
|
11538
|
+
console.log(`[scheduler] gate authority: ${resolved.slug} completed — gate re-run green at ${headShort}, verdict ${resolved.verdict} overridden`);
|
|
11539
|
+
}
|
|
10967
11540
|
await broadcast();
|
|
10968
11541
|
return gateShadow;
|
|
10969
11542
|
}
|
|
10970
11543
|
|
|
10971
11544
|
// Tail of the last background shadow gate — lets tests (and only tests) await it.
|
|
10972
11545
|
let gateShadowPending = null;
|
|
11546
|
+
// The slug the background shadow gate is currently deciding, from the pick
|
|
11547
|
+
// below until that gate run finishes. Lets the auto-fix loop (and the
|
|
11548
|
+
// deferred-investigation drain) skip this one row while the gate still has
|
|
11549
|
+
// it — one row gets one recovery attempt at a time.
|
|
11550
|
+
let gateShadowSlug = null;
|
|
11551
|
+
|
|
11552
|
+
/**
|
|
11553
|
+
* Pure: picks the one needs_review row reverifyNeedsReview's background
|
|
11554
|
+
* shadow gate should run this pass, or null when none qualifies. No I/O:
|
|
11555
|
+
* `headByCwd` is a Map of cwd -> HEAD sha (or null), pre-fetched by the
|
|
11556
|
+
* caller with `gitHead` (which never rejects, so building it needs no
|
|
11557
|
+
* try/catch — a cwd whose read failed just maps to null here).
|
|
11558
|
+
*
|
|
11559
|
+
* A row is never picked when its cwd's HEAD is unknown (null) — there is
|
|
11560
|
+
* nothing to compare a recorded gateShadow against.
|
|
11561
|
+
*
|
|
11562
|
+
* Tier 1, skipped when `authorityDisabled`: a stale, gate-authority-eligible
|
|
11563
|
+
* row — verdict in GATE_AUTHORITY_VERDICTS, a non-empty landedCommit, and
|
|
11564
|
+
* stale (no gateShadow, or one at a different HEAD, or one not marked
|
|
11565
|
+
* `definitive`). Ordered so a row that never got a shadow run goes first,
|
|
11566
|
+
* then the oldest `gateShadow.ranAt` (an unparseable or missing `ranAt`
|
|
11567
|
+
* sorts as oldest), then queue order. Rule first, why second: the old pick
|
|
11568
|
+
* took the first stale row in queue order with no rotation, so in a busy
|
|
11569
|
+
* plan one row whose gate stays red forever could crowd out every other
|
|
11570
|
+
* row's turn (review finding #8).
|
|
11571
|
+
*
|
|
11572
|
+
* Tier 2: the first needs_review row with no gateShadow at all, in queue
|
|
11573
|
+
* order — today's plain observe-only rule, unchanged.
|
|
11574
|
+
*
|
|
11575
|
+
* Returns the first tier-1 row, else the tier-2 row, else null.
|
|
11576
|
+
*/
|
|
11577
|
+
function selectGateShadowTarget(jobs, headByCwd, { authorityDisabled = false } = {}) {
|
|
11578
|
+
const eligible = jobs.filter((j) => {
|
|
11579
|
+
if (j.status !== 'needs_review') return false;
|
|
11580
|
+
const head = headByCwd.get(j.cwd);
|
|
11581
|
+
return typeof head === 'string' && head.length > 0;
|
|
11582
|
+
});
|
|
11583
|
+
|
|
11584
|
+
const isStale = (j) => {
|
|
11585
|
+
const head = headByCwd.get(j.cwd);
|
|
11586
|
+
return !j.gateShadow || j.gateShadow.head !== head || j.gateShadow.definitive !== true;
|
|
11587
|
+
};
|
|
11588
|
+
|
|
11589
|
+
if (!authorityDisabled) {
|
|
11590
|
+
const ranAtMs = (j) => {
|
|
11591
|
+
const t = j.gateShadow ? Date.parse(j.gateShadow.ranAt) : NaN;
|
|
11592
|
+
return Number.isFinite(t) ? t : -Infinity; // never-run or unparseable sorts oldest
|
|
11593
|
+
};
|
|
11594
|
+
const tier1 = eligible.filter((j) => isStale(j)
|
|
11595
|
+
&& j.verifierVerdict
|
|
11596
|
+
&& GATE_AUTHORITY_VERDICTS.includes(j.verifierVerdict)
|
|
11597
|
+
&& typeof j.landedCommit === 'string'
|
|
11598
|
+
&& j.landedCommit.length > 0);
|
|
11599
|
+
if (tier1.length) {
|
|
11600
|
+
const ranked = tier1
|
|
11601
|
+
.map((j, i) => ({ j, i, neverRun: !j.gateShadow, ranAt: ranAtMs(j) }))
|
|
11602
|
+
.sort((a, b) => {
|
|
11603
|
+
if (a.neverRun !== b.neverRun) return a.neverRun ? -1 : 1;
|
|
11604
|
+
if (a.ranAt !== b.ranAt) return a.ranAt - b.ranAt;
|
|
11605
|
+
return a.i - b.i; // tie: queue order
|
|
11606
|
+
});
|
|
11607
|
+
return ranked[0].j;
|
|
11608
|
+
}
|
|
11609
|
+
}
|
|
11610
|
+
|
|
11611
|
+
return eligible.find((j) => !j.gateShadow) ?? null;
|
|
11612
|
+
}
|
|
10973
11613
|
|
|
10974
11614
|
async function reverifyNeedsReview() {
|
|
10975
11615
|
const snap = await readQueue();
|
|
@@ -11097,15 +11737,48 @@ async function reverifyNeedsReview() {
|
|
|
11097
11737
|
}
|
|
11098
11738
|
}
|
|
11099
11739
|
}
|
|
11100
|
-
// Shadow gate (observation only
|
|
11101
|
-
//
|
|
11740
|
+
// Shadow gate (observation only, or — for a transcript-noise verdict with
|
|
11741
|
+
// a landed commit — gate AUTHORITY: a green re-run completes the row). At
|
|
11742
|
+
// most one needs_review row per pass, fired in the background so a long
|
|
11743
|
+
// gate never stalls this pass. selectGateShadowTarget does the picking: a
|
|
11744
|
+
// never-run or stale authority-eligible row first (oldest shadow run
|
|
11745
|
+
// first), else the first row that never got a shadow run at all — so one
|
|
11746
|
+
// row whose gate stays red forever can't crowd out every other row's turn.
|
|
11747
|
+
// gateShadowSlug names the picked row for as long as its gate is running,
|
|
11748
|
+
// so the auto-fix loop below (and the deferred-investigation drain) can
|
|
11749
|
+
// leave that one row alone — the gate is cheap and goes first; a probe
|
|
11750
|
+
// only gets the row if the gate didn't resolve it.
|
|
11751
|
+
//
|
|
11752
|
+
// The pick itself is async (it reads HEAD per candidate's cwd), so it runs
|
|
11753
|
+
// inside its own promise, `pick`, resolved before `gateShadowPending`
|
|
11754
|
+
// chains the gate run after it. `gateShadowPending` is still assigned
|
|
11755
|
+
// synchronously, before any await, exactly as before — otherwise a second
|
|
11756
|
+
// pass starting before this one's pick resolves could choose a target of
|
|
11757
|
+
// its own too, breaking the single-flight rule.
|
|
11758
|
+
let pick = null;
|
|
11102
11759
|
if (!gateShadowPending && process.env.SM_GATE_SHADOW_DISABLE !== '1') {
|
|
11103
|
-
|
|
11104
|
-
|
|
11105
|
-
|
|
11106
|
-
|
|
11107
|
-
.
|
|
11108
|
-
|
|
11760
|
+
pick = (async () => {
|
|
11761
|
+
const needsReview = snap.jobs.filter((j) => j.status === 'needs_review');
|
|
11762
|
+
const headByCwd = new Map();
|
|
11763
|
+
for (const cwd of new Set(needsReview.map((j) => j.cwd))) {
|
|
11764
|
+
headByCwd.set(cwd, await gitHead(cwd));
|
|
11765
|
+
}
|
|
11766
|
+
return selectGateShadowTarget(snap.jobs, headByCwd, {
|
|
11767
|
+
authorityDisabled: process.env.SM_GATE_AUTHORITATIVE_DISABLE === '1',
|
|
11768
|
+
});
|
|
11769
|
+
})();
|
|
11770
|
+
gateShadowPending = pick
|
|
11771
|
+
.then((gateTarget) => {
|
|
11772
|
+
if (!gateTarget) return;
|
|
11773
|
+
gateShadowSlug = gateTarget.slug;
|
|
11774
|
+
return runGateShadow(gateTarget);
|
|
11775
|
+
})
|
|
11776
|
+
.catch((e) => { console.error('[scheduler] gate shadow error', gateShadowSlug, e); })
|
|
11777
|
+
.finally(() => {
|
|
11778
|
+
gateShadowPending = null;
|
|
11779
|
+
gateShadowSlug = null;
|
|
11780
|
+
drainDeferredInvestigation();
|
|
11781
|
+
});
|
|
11109
11782
|
}
|
|
11110
11783
|
if (evidenceScanned.length) {
|
|
11111
11784
|
const scannedSet = new Set(evidenceScanned);
|
|
@@ -11329,9 +12002,14 @@ async function reverifyNeedsReview() {
|
|
|
11329
12002
|
// MAX_CONCURRENT_INVESTIGATIONS (queues the rest for retry), so this loop
|
|
11330
12003
|
// cannot fan out past the cap regardless of how many targets are selected.
|
|
11331
12004
|
if (process.env.SM_AUTOFIX_DISABLE !== '1') {
|
|
12005
|
+
// If this pass started a gate-shadow pick above, wait for it — it only
|
|
12006
|
+
// reads HEADs, so this is a short wait, not the gate run itself — then
|
|
12007
|
+
// drop its target from this loop's candidates. The gate gets first shot
|
|
12008
|
+
// at that one row; a probe only takes it if the gate didn't resolve it.
|
|
12009
|
+
if (pick) await pick;
|
|
11332
12010
|
const targets = selectAutoFixTargets(queueForResumeAndAutofix.jobs, {
|
|
11333
12011
|
fixSlugExists: (s) => candidatePrdsDirs().some((dir) => fs.existsSync(path.join(dir, `${s}.md`))),
|
|
11334
|
-
});
|
|
12012
|
+
}).filter((job) => job.slug !== gateShadowSlug);
|
|
11335
12013
|
for (const job of targets) {
|
|
11336
12014
|
const runId = job.runId || resolveRunId(job);
|
|
11337
12015
|
const runDir = path.join(schedulerPaths.runsDir(), runId);
|
|
@@ -11549,22 +12227,25 @@ function registerScheduleHandlers() {
|
|
|
11549
12227
|
return { ok: true, config };
|
|
11550
12228
|
}));
|
|
11551
12229
|
|
|
11552
|
-
ipcMain.handle('schedule:reset-job', validated(schemas.
|
|
12230
|
+
ipcMain.handle('schedule:reset-job', validated(schemas.scheduleResetJob, async ({ slug, cwd }) => {
|
|
11553
12231
|
if (!(await safeSlugPath(slug))) return { ok: false, error: 'invalid slug' };
|
|
11554
12232
|
const outcome = await mutate((state) => {
|
|
11555
|
-
const idx = state.jobs.findIndex((j) => j
|
|
11556
|
-
if (idx < 0) return 'not-found';
|
|
12233
|
+
const idx = state.jobs.findIndex((j) => jobMatchesSlugAndCwd(j, slug, cwd));
|
|
12234
|
+
if (idx < 0) return { kind: 'not-found' };
|
|
11557
12235
|
// Guard is in resetJobFields: refuses to reset an already-'completed'
|
|
11558
|
-
//
|
|
11559
|
-
//
|
|
11560
|
-
|
|
12236
|
+
// or already-'skipped' job without force:true (see its doc comment for
|
|
12237
|
+
// why each is refused). This handler never passes force — there is no
|
|
12238
|
+
// force option on the renderer IPC path — so resetRefusalMessage's
|
|
12239
|
+
// canForce:false tells the caller to use scheduler_reset_job instead.
|
|
12240
|
+
const status = state.jobs[idx].status;
|
|
12241
|
+
if (!resetJobFields(state.jobs[idx], null, { source: 'ipc:schedule:reset-job' })) {
|
|
12242
|
+
return { kind: 'refused', status };
|
|
12243
|
+
}
|
|
12244
|
+
return { kind: 'ok' };
|
|
11561
12245
|
});
|
|
11562
|
-
if (outcome === 'not-found') return { ok: false, error: 'not found' };
|
|
11563
|
-
if (outcome === 'refused') {
|
|
11564
|
-
return {
|
|
11565
|
-
ok: false,
|
|
11566
|
-
error: 'job already completed — resetting it would re-execute shipped work; archive the PRD instead',
|
|
11567
|
-
};
|
|
12246
|
+
if (outcome.kind === 'not-found') return { ok: false, error: 'not found' };
|
|
12247
|
+
if (outcome.kind === 'refused') {
|
|
12248
|
+
return { ok: false, error: resetRefusalMessage(outcome.status, { canForce: false }) };
|
|
11568
12249
|
}
|
|
11569
12250
|
await broadcast({ flush: true });
|
|
11570
12251
|
return { ok: true };
|
|
@@ -11996,7 +12677,18 @@ function rescheduleIntervalTick() {
|
|
|
11996
12677
|
);
|
|
11997
12678
|
}
|
|
11998
12679
|
}
|
|
11999
|
-
}).catch(() => {})
|
|
12680
|
+
}).catch(() => {})
|
|
12681
|
+
.finally(() => {
|
|
12682
|
+
// An auto-resolve skip just made a notice due in THIS pass — flush
|
|
12683
|
+
// right away instead of waiting for the next one. Fire-and-forget:
|
|
12684
|
+
// never blocks this tick, never throws (flushDueReviewNotices
|
|
12685
|
+
// catches internally).
|
|
12686
|
+
flushDueReviewNotices().catch(() => {});
|
|
12687
|
+
});
|
|
12688
|
+
} else if (s.jobs.some((j) => j.reviewNotice && !j.reviewNotice.sentAt
|
|
12689
|
+
&& (j.status === 'needs_review' || (j.status === 'skipped' && j.needsReviewAutoResolvedSkip)))) {
|
|
12690
|
+
// Ladder didn't fire this tick (row ineligible for auto-resolve, or SM_NEEDS_REVIEW_AUTORESOLVE_DISABLE=1) — flush any notice whose hold already expired, reusing this tick's own snapshot for the guard so a tick with nothing unsent reads nothing extra. Status-checked so a row that healed (e.g. a human reset, or a later run that completed) with a leftover unsent notice from a past episode no longer costs a queue read every tick — resetJobFields already deletes the notice outright, but this guard stays defensive in case a notice is ever left behind some other way.
|
|
12691
|
+
flushDueReviewNotices().catch(() => {});
|
|
12000
12692
|
}
|
|
12001
12693
|
}
|
|
12002
12694
|
|
|
@@ -12401,7 +13093,8 @@ const remote = {
|
|
|
12401
13093
|
// realpath resolves symlinks; re-check boundary to block a rogue agent job
|
|
12402
13094
|
// that places a symlink inside the PRDs dir pointing outside the safe root.
|
|
12403
13095
|
const real = await fsp.realpath(filePath);
|
|
12404
|
-
|
|
13096
|
+
const realDir = await realPrdsDir(dir);
|
|
13097
|
+
if (!realDir || !real.startsWith(realDir + path.sep)) {
|
|
12405
13098
|
return { ok: false, error: 'invalid slug' };
|
|
12406
13099
|
}
|
|
12407
13100
|
const text = await fsp.readFile(real, 'utf8');
|
|
@@ -12478,7 +13171,8 @@ const remote = {
|
|
|
12478
13171
|
// re-assert containment; also reject the target if it is already a
|
|
12479
13172
|
// symlink.
|
|
12480
13173
|
const realParent = await fsp.realpath(path.dirname(resolved));
|
|
12481
|
-
|
|
13174
|
+
const realDir = await realPrdsDir(dir);
|
|
13175
|
+
if (!realDir || (realParent !== realDir && !realParent.startsWith(realDir + path.sep))) {
|
|
12482
13176
|
return { ok: false, error: 'invalid slug' };
|
|
12483
13177
|
}
|
|
12484
13178
|
const existing = await fsp.lstat(resolved).catch(() => null);
|
|
@@ -12529,25 +13223,23 @@ const remote = {
|
|
|
12529
13223
|
return { ok: false, error: resolved.reason === 'invalid-slug' ? 'invalid slug' : unknownSlugMessage(slug) };
|
|
12530
13224
|
}
|
|
12531
13225
|
const outcome = await mutate((state) => {
|
|
12532
|
-
// Same cwd filter as
|
|
12533
|
-
//
|
|
12534
|
-
|
|
12535
|
-
// reset THAT project's job, not just any queue row matching the string.
|
|
12536
|
-
const idx = state.jobs.findIndex((j) => j.slug === slug && (!opts.cwd || j.cwd === opts.cwd));
|
|
13226
|
+
// Same cwd filter as the schedule:reset-job IPC handler above and
|
|
13227
|
+
// resolveSlugOrReason's file lookup — see jobMatchesSlugAndCwd's header.
|
|
13228
|
+
const idx = state.jobs.findIndex((j) => jobMatchesSlugAndCwd(j, slug, opts.cwd));
|
|
12537
13229
|
if (idx < 0) return { kind: 'not-found' };
|
|
12538
13230
|
// Terminal-status guard lives in resetJobFields itself; force:true
|
|
12539
|
-
// threads through to override it.
|
|
13231
|
+
// threads through to override it. Capture the pre-reset status here
|
|
13232
|
+
// (inside the same mutate callback) so a refusal can report exactly
|
|
13233
|
+
// which status blocked it, via resetRefusalMessage below.
|
|
13234
|
+
const status = state.jobs[idx].status;
|
|
12540
13235
|
if (!resetJobFields(state.jobs[idx], null, { force: opts.force === true, source: 'remote:resetJob' })) {
|
|
12541
|
-
return { kind: 'refused' };
|
|
13236
|
+
return { kind: 'refused', status };
|
|
12542
13237
|
}
|
|
12543
13238
|
return { kind: 'ok' };
|
|
12544
13239
|
});
|
|
12545
13240
|
if (outcome.kind === 'not-found') return { ok: false, error: 'not found' };
|
|
12546
13241
|
if (outcome.kind === 'refused') {
|
|
12547
|
-
return {
|
|
12548
|
-
ok: false,
|
|
12549
|
-
error: 'job already completed — resetting it would re-execute shipped work; archive the PRD instead, or pass force:true',
|
|
12550
|
-
};
|
|
13242
|
+
return { ok: false, error: resetRefusalMessage(outcome.status, { canForce: true }) };
|
|
12551
13243
|
}
|
|
12552
13244
|
await broadcast({ flush: true });
|
|
12553
13245
|
return { ok: true, slug, status: 'pending' };
|
|
@@ -12623,7 +13315,8 @@ const remote = {
|
|
|
12623
13315
|
// Symlink defense, matching readPrd/writePrd's comment: safeSlugPathIn
|
|
12624
13316
|
// is lexical and does not resolve symlinks.
|
|
12625
13317
|
const real = await fsp.realpath(filePath);
|
|
12626
|
-
|
|
13318
|
+
const realDir = await realPrdsDir(dir);
|
|
13319
|
+
if (!realDir || !real.startsWith(realDir + path.sep)) return { ok: false, error: 'invalid slug' };
|
|
12627
13320
|
const [raw, parsed] = await Promise.all([fsp.readFile(real, 'utf8'), prdParser.parsePrdRaw(real)]);
|
|
12628
13321
|
return {
|
|
12629
13322
|
ok: true,
|
|
@@ -12648,20 +13341,32 @@ const remote = {
|
|
|
12648
13341
|
}
|
|
12649
13342
|
},
|
|
12650
13343
|
|
|
12651
|
-
// Edits a
|
|
12652
|
-
//
|
|
12653
|
-
//
|
|
12654
|
-
//
|
|
13344
|
+
// Edits a PRD's frontmatter and/or body in place. Works when there is no
|
|
13345
|
+
// queue row yet, and when the row's status is 'pending', 'quarantined',
|
|
13346
|
+
// 'needs_review', 'failed', or 'skipped'. None of those have a live
|
|
13347
|
+
// executor reading the file right now, so a rewrite is safe. A parked
|
|
13348
|
+
// (needs_review/failed) or skipped job is exactly the planner-repair case:
|
|
13349
|
+
// fix the spec here, then reset the job with scheduler_reset_job, which
|
|
13350
|
+
// clears the old run fields. 'quarantined' is also editable for a second
|
|
13351
|
+
// reason: it's the ONLY way a quarantined PRD's createdVia stamp gets
|
|
13352
|
+
// written (the adopt action below), so refusing it here would make
|
|
13353
|
+
// quarantine irreversible through the API. Refuses 'running' (a live
|
|
13354
|
+
// executor could read the file mid-edit) and 'completed' (the work already
|
|
13355
|
+
// landed — queue a new PRD instead of rewriting a finished one). Reuses
|
|
12655
13356
|
// prdFrontmatter.cjs's parsePrdFile/serializePrdFile round-trip pair (PRD
|
|
12656
13357
|
// 1024) so unrecognized keys (e.g. dependsOn) and untouched recognized
|
|
12657
13358
|
// keys' original line formatting survive unchanged.
|
|
12658
13359
|
async updatePrd({ slug, cwd, frontmatter, body }) {
|
|
12659
13360
|
const job = await this.getJob(slug);
|
|
12660
|
-
|
|
12661
|
-
|
|
12662
|
-
|
|
12663
|
-
|
|
12664
|
-
|
|
13361
|
+
const EDITABLE_JOB_STATUSES = new Set(['pending', 'quarantined', 'needs_review', 'failed', 'skipped']);
|
|
13362
|
+
if (job && !EDITABLE_JOB_STATUSES.has(job.status)) {
|
|
13363
|
+
if (job.status === 'running') {
|
|
13364
|
+
return { ok: false, error: 'job status is "running" — wait for it to end, or stop it with scheduler_cancel_job, then edit it.' };
|
|
13365
|
+
}
|
|
13366
|
+
if (job.status === 'completed') {
|
|
13367
|
+
return { ok: false, error: 'job status is "completed" — its work already landed. Queue a new PRD for more work.' };
|
|
13368
|
+
}
|
|
13369
|
+
return { ok: false, error: `job status is "${job.status}" — this PRD cannot be edited now.` };
|
|
12665
13370
|
}
|
|
12666
13371
|
|
|
12667
13372
|
// Write-time FK check for a patched dependsOn (PRD 1124), reusing the
|
|
@@ -12715,7 +13420,8 @@ const remote = {
|
|
|
12715
13420
|
// target that is itself already a symlink — a rogue job could plant
|
|
12716
13421
|
// one inside the PRDs dir pointing outside the safe root.
|
|
12717
13422
|
const real = await fsp.realpath(filePath);
|
|
12718
|
-
|
|
13423
|
+
const realDir = await realPrdsDir(dir);
|
|
13424
|
+
if (!realDir || !real.startsWith(realDir + path.sep)) return { ok: false, error: 'invalid slug' };
|
|
12719
13425
|
const existing = await fsp.lstat(filePath).catch(() => null);
|
|
12720
13426
|
if (existing && existing.isSymbolicLink()) return { ok: false, error: 'invalid slug' };
|
|
12721
13427
|
raw = await fsp.readFile(real, 'utf8');
|
|
@@ -12959,6 +13665,7 @@ module.exports = {
|
|
|
12959
13665
|
reverifyNeedsReview,
|
|
12960
13666
|
runGateShadow,
|
|
12961
13667
|
awaitGateShadowIdle: async () => { while (gateShadowPending) await gateShadowPending; },
|
|
13668
|
+
selectGateShadowTarget,
|
|
12962
13669
|
shouldRunPeriodicReverify,
|
|
12963
13670
|
findStuckFailedJobs,
|
|
12964
13671
|
STUCK_FAILED_ESCALATE_MS,
|
|
@@ -13038,6 +13745,7 @@ module.exports = {
|
|
|
13038
13745
|
registerAdminRoutes,
|
|
13039
13746
|
notifyOriginatingTab,
|
|
13040
13747
|
notifyNeedsReview,
|
|
13748
|
+
flushDueReviewNotices,
|
|
13041
13749
|
isNotifiableTerminalStatus,
|
|
13042
13750
|
extractResultTextFromLog,
|
|
13043
13751
|
candidatePrdsDirs,
|
|
@@ -13048,6 +13756,7 @@ module.exports = {
|
|
|
13048
13756
|
archivedPrdPathForJob,
|
|
13049
13757
|
archivedTwinExists,
|
|
13050
13758
|
findPrdDir,
|
|
13759
|
+
findPrdDirForJob,
|
|
13051
13760
|
resolveVerifyPrdPath,
|
|
13052
13761
|
resolveFixPlanPath,
|
|
13053
13762
|
resolveNotifyPrd,
|
|
@@ -13059,6 +13768,8 @@ module.exports = {
|
|
|
13059
13768
|
SCHEDULER_BOOTED_AT,
|
|
13060
13769
|
SCHEDULER_CODE_SHA,
|
|
13061
13770
|
resetJobFields,
|
|
13771
|
+
resetRefusalMessage,
|
|
13772
|
+
jobMatchesSlugAndCwd,
|
|
13062
13773
|
executeJob,
|
|
13063
13774
|
killOrphanClaudePid,
|
|
13064
13775
|
prdArchivedSkipResult,
|
|
@@ -13090,6 +13801,7 @@ module.exports = {
|
|
|
13090
13801
|
selectResumeRecoveryTarget,
|
|
13091
13802
|
buildResumeRecoveryPreamble,
|
|
13092
13803
|
buildClaudeSpawnArgs,
|
|
13804
|
+
HEADLESS_DISALLOWED_TOOLS,
|
|
13093
13805
|
spawnResumeRecovery,
|
|
13094
13806
|
selectMechanicalRecoveryTarget,
|
|
13095
13807
|
isMechanicalRecoveryFutile,
|