claude-code-session-manager 0.80.0 → 0.81.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/dist/assets/{AgentLibrary-zS3jw_1e.js → AgentLibrary-psZYVM2w.js} +1 -1
- package/dist/assets/{DataModel-Cy_vxTpi.js → DataModel-BMied5pg.js} +1 -1
- package/dist/assets/{History-C6JRuqfT.js → History-BFC0oaKc.js} +1 -1
- package/dist/assets/{Hooks-BafPy9mB.js → Hooks-CTLfO9G8.js} +1 -1
- package/dist/assets/{HostBilko-BZwhQOFt.js → HostBilko-D4I0Cpwn.js} +1 -1
- package/dist/assets/{Library-C8JDDliz.js → Library-BTzS8KsS.js} +1 -1
- package/dist/assets/{ListDetail-CqiOdwLc.js → ListDetail-CuRmT008.js} +1 -1
- package/dist/assets/{MarkdownEditor-CyLyP67L.js → MarkdownEditor-BgQGtvWo.js} +1 -1
- package/dist/assets/{McpServers-BzMv-_84.js → McpServers-DiVQUK57.js} +1 -1
- package/dist/assets/{Memory-DSBYQdJR.js → Memory-DSu15JmL.js} +1 -1
- package/dist/assets/{Panel-CLUhkNNA.js → Panel-CD5wxGSR.js} +1 -1
- package/dist/assets/{Permissions-BfC2-HN4.js → Permissions-C_tAE6yJ.js} +1 -1
- package/dist/assets/{Plugins-BKi40jT5.js → Plugins-kwI7W-eK.js} +2 -2
- package/dist/assets/{ProvenanceBadge-BzFw4KhD.js → ProvenanceBadge-CQceOgsH.js} +1 -1
- package/dist/assets/{SaveBar-avk2p9jv.js → SaveBar-BDk5e3Pp.js} +1 -1
- package/dist/assets/{Scheduler-Bf_6MdJo.js → Scheduler-DRnvEYzv.js} +1 -1
- package/dist/assets/{ScopeSwitcher-C-RwYUVZ.js → ScopeSwitcher-CeifaOlq.js} +1 -1
- package/dist/assets/{Settings-Djd8OoBA.js → Settings-BWQ1Utop.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-DuogY6s7.js → SkillReferenceGraph-CW6e1SW1.js} +1 -1
- package/dist/assets/{Skills-D_qAqxZ_.js → Skills-BLwFB0E4.js} +1 -1
- package/dist/assets/{SystemPrompt-DbHFLQV3.js → SystemPrompt-DC4ZTArJ.js} +1 -1
- package/dist/assets/{TagLibrary-C2y91BT0.js → TagLibrary-pMeGfjUb.js} +1 -1
- package/dist/assets/{TiptapBody-D9iz4xQx.js → TiptapBody-BHFid2pZ.js} +1 -1
- package/dist/assets/{Toggle-BGnFL2E5.js → Toggle-D9eYoZh4.js} +1 -1
- package/dist/assets/{index-_2ARyFDj.js → index-CuyM9vAP.js} +4 -4
- package/dist/assets/{settingsSchema-JK15eJU8.js → settingsSchema-DrxC67uZ.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/scripts/project-pages-logic/dist/logic.cjs +12 -12
- package/scripts/render-project-pages/dist/renderer.cjs +22 -22
- package/src/main/__tests__/rcaReport.test.cjs +24 -0
- package/src/main/__tests__/runVerify-blocked-by-foreign-wip.test.cjs +58 -0
- package/src/main/__tests__/runVerify-policy-denial.test.cjs +89 -0
- package/src/main/__tests__/scheduler-already-satisfied-on-main.test.cjs +105 -0
- package/src/main/__tests__/scheduler-blocked-by-foreign-wip.test.cjs +107 -0
- package/src/main/__tests__/scheduler-finalize-dispatch-guards.test.cjs +229 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +141 -1
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +81 -0
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +205 -2
- package/src/main/lib/__tests__/branchSweep.test.cjs +164 -0
- package/src/main/lib/__tests__/fixtures/204-mercury-steam-horse.log.txt +13 -0
- package/src/main/lib/__tests__/landedSinceRun.test.cjs +61 -1
- package/src/main/lib/__tests__/rateLimitWindow.test.cjs +88 -0
- package/src/main/lib/__tests__/reaperHelpers.test.cjs +120 -1
- package/src/main/lib/branchSweep.cjs +127 -0
- package/src/main/lib/gitWorktree.cjs +20 -0
- package/src/main/lib/landedSinceRun.cjs +41 -1
- package/src/main/lib/rateLimitWindow.cjs +62 -0
- package/src/main/lib/rcaReport.cjs +18 -3
- package/src/main/lib/reaperHelpers.cjs +169 -3
- package/src/main/lib/scheduleJobTransitions.cjs +16 -3
- package/src/main/runVerify.cjs +71 -3
- package/src/main/scheduler.cjs +741 -31
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[scheduler] starting 204-mercury-steam-horse at 2026-09-05T16:32:31.039Z
|
|
2
|
+
[scheduler] cwd=/home/bilko/Projects/starry-night-ships (running isolated in worktree /tmp/session-manager-job-worktrees/443997d5bbc5f8ab/204-mercury-steam-horse)
|
|
3
|
+
|
|
4
|
+
[scheduler] agentType=dev-lead persona=/home/bilko/.claude/agents/dev-lead.md model=fable
|
|
5
|
+
[scheduler] spawned pid=3537290 sessionId=5cb9d69b-e2e7-49f9-806c-2307f1c94b2b (process group)
|
|
6
|
+
|
|
7
|
+
{"type":"rate_limit_event","rate_limit_info":{"status":"allowed_warning","resetsAt":1789059600,"rateLimitType":"seven_day","utilization":0.59,"isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0,"resetsAt":1788643800},"seven_day":{"utilization":0.59,"resetsAt":1789059600}}},"uuid":"f7ef520a-9c3f-470e-89de-4ba9af7d5968","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b"}
|
|
8
|
+
{"type":"system","subtype":"init","cwd":"/tmp/session-manager-job-worktrees/443997d5bbc5f8ab/204-mercury-steam-horse","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","tools":["Task","Bash","CronCreate","CronDelete","CronList","DesignSync","Edit","EnterWorktree","ExitWorktree","ListAgents","ListMcpResourcesTool","Monitor","NotebookEdit","PushNotification","Read","ReadMcpResourceDirTool","ReadMcpResourceTool","RemoteTrigger","ReportFindings","ScheduleWakeup","SendMessage","Skill","TaskOutput","TaskStop","ToolSearch","WebFetch","WebSearch","Workflow","Write","mcp__bilko-host__get_host_contract","mcp__bilko-host__list_projects","mcp__bilko-host__publish_static_project","mcp__bilko-host__register_static_project","mcp__bilko-host__status","mcp__bilko-host__unregister_project","mcp__bilko-host__usage_report","mcp__claude_ai_Gmail__apply_sensitive_message_label","mcp__claude_ai_Gmail__apply_sensitive_thread_label","mcp__claude_ai_Gmail__create_draft","mcp__claude_ai_Gmail__create_label","mcp__claude_ai_Gmail__delete_label","mcp__claude_ai_Gmail__forward","mcp__claude_ai_Gmail__get_draft","mcp__claude_ai_Gmail__get_message","mcp__claude_ai_Gmail__get_thread","mcp__claude_ai_Gmail__label_message","mcp__claude_ai_Gmail__label_thread","mcp__claude_ai_Gmail__list_drafts","mcp__claude_ai_Gmail__list_labels","mcp__claude_ai_Gmail__mark_message_spam","mcp__claude_ai_Gmail__mark_thread_spam","mcp__claude_ai_Gmail__reply","mcp__claude_ai_Gmail__search_threads","mcp__claude_ai_Gmail__send_message","mcp__claude_ai_Gmail__trash_message","mcp__claude_ai_Gmail__trash_thread","mcp__claude_ai_Gmail__unlabel_message","mcp__claude_ai_Gmail__unlabel_thread","mcp__claude_ai_Gmail__unmark_message_spam","mcp__claude_ai_Gmail__unmark_thread_spam","mcp__claude_ai_Gmail__untrash_message","mcp__claude_ai_Gmail__untrash_thread","mcp__claude_ai_Gmail__update_draft","mcp__claude_ai_Gmail__update_label","mcp__claude_ai_Gmail__update_message_labels","mcp__fetch__fetch","mcp__godot__add_node","mcp__godot__create_scene","mcp__godot__export_mesh_library","mcp__godot__get_debug_output","mcp__godot__get_godot_version","mcp__godot__get_project_info","mcp__godot__get_uid","mcp__godot__launch_editor","mcp__godot__list_projects","mcp__godot__load_sprite","mcp__godot__run_project","mcp__godot__save_scene","mcp__godot__stop_project","mcp__godot__update_project_uids","mcp__google-workspace__batch_modify_gmail_message_labels","mcp__google-workspace__batch_update_doc","mcp__google-workspace__check_drive_file_public_access","mcp__google-workspace__copy_drive_file","mcp__google-workspace__create_calendar","mcp__google-workspace__create_doc","mcp__google-workspace__create_drive_file","mcp__google-workspace__create_drive_folder","mcp__google-workspace__create_reaction","mcp__google-workspace__create_table_with_data","mcp__google-workspace__debug_docs_runtime_info","mcp__google-workspace__debug_table_structure","mcp__google-workspace__download_chat_attachment","mcp__google-workspace__draft_gmail_message","mcp__google-workspace__export_doc_to_pdf","mcp__google-workspace__find_and_replace_doc","mcp__google-workspace__get_doc_as_markdown","mcp__google-workspace__get_doc_content","mcp__google-workspace__get_drive_file_content","mcp__google-workspace__get_drive_file_download_url","mcp__google-workspace__get_drive_file_permissions","mcp__google-workspace__get_drive_shareable_link","mcp__google-workspace__get_events","mcp__google-workspace__get_gmail_attachment_content","mcp__google-workspace__get_gmail_message_content","mcp__google-workspace__get_gmail_messages_content_batch","mcp__google-workspace__get_gmail_thread_content","mcp__google-workspace__get_gmail_threads_content_batch","mcp__google-workspace__get_messages","mcp__google-workspace__import_to_google_doc","mcp__google-workspace__import_to_google_sheets","mcp__google-workspace__import_to_google_slides","mcp__google-workspace__insert_doc_elements","mcp__google-workspace__insert_doc_image","mcp__google-workspace__inspect_doc_structure","mcp__google-workspace__list_calendars","mcp__google-workspace__list_docs_in_folder","mcp__google-workspace__list_document_comments","mcp__google-workspace__list_drive_items","mcp__google-workspace__list_gmail_filters","mcp__google-workspace__list_gmail_labels","mcp__google-workspace__list_spaces","mcp__google-workspace__manage_doc_tab","mcp__google-workspace__manage_document_comment","mcp__google-workspace__manage_drive_access","mcp__google-workspace__manage_event","mcp__google-workspace__manage_focus_time","mcp__google-workspace__manage_gmail_filter","mcp__google-workspace__manage_gmail_label","mcp__google-workspace__manage_out_of_office","mcp__google-workspace__modify_doc_text","mcp__google-workspace__modify_gmail_message_labels","mcp__google-workspace__query_freebusy","mcp__google-workspace__search_docs","mcp__google-workspace__search_drive_files","mcp__google-workspace__search_gmail_messages","mcp__google-workspace__search_messages","mcp__google-workspace__send_gmail_message","mcp__google-workspace__send_message","mcp__google-workspace__set_drive_file_permissions","mcp__google-workspace__start_google_auth","mcp__google-workspace__update_doc_headers_footers","mcp__google-workspace__update_drive_file","mcp__google-workspace__update_paragraph_style","mcp__playwright__browser_click","mcp__playwright__browser_close","mcp__playwright__browser_console_messages","mcp__playwright__browser_drag","mcp__playwright__browser_drop","mcp__playwright__browser_evaluate","mcp__playwright__browser_file_upload","mcp__playwright__browser_fill_form","mcp__playwright__browser_handle_dialog","mcp__playwright__browser_hover","mcp__playwright__browser_navigate","mcp__playwright__browser_navigate_back","mcp__playwright__browser_network_request","mcp__playwright__browser_network_requests","mcp__playwright__browser_press_key","mcp__playwright__browser_resize","mcp__playwright__browser_run_code_unsafe","mcp__playwright__browser_select_option","mcp__playwright__browser_snapshot","mcp__playwright__browser_tabs","mcp__playwright__browser_take_screenshot","mcp__playwright__browser_type","mcp__playwright__browser_wait_for","mcp__session-manager-scheduler__chat_send_prompt","mcp__session-manager-scheduler__feedback_list_projects","mcp__session-manager-scheduler__feedback_open_session","mcp__session-manager-scheduler__project_home_get_contract","mcp__session-manager-scheduler__project_home_render","mcp__session-manager-scheduler__project_home_status","mcp__session-manager-scheduler__project_home_validate_summary","mcp__session-manager-scheduler__scheduler_archive_prd","mcp__session-manager-scheduler__scheduler_cancel_job","mcp__session-manager-scheduler__scheduler_create_prd","mcp__session-manager-scheduler__scheduler_get_prd","mcp__session-manager-scheduler__scheduler_list_jobs","mcp__session-manager-scheduler__scheduler_list_prds","mcp__session-manager-scheduler__scheduler_reset_job","mcp__session-manager-scheduler__scheduler_retag_prd","mcp__session-manager-scheduler__scheduler_update_prd","mcp__session-manager-scheduler__session_manager_help"],"mcp_servers":[{"name":"plugin:stripe:stripe","status":"needs-auth"},{"name":"fetch","status":"connected"},{"name":"sqlite","status":"failed"},{"name":"playwright","status":"connected"},{"name":"google-workspace","status":"connected"},{"name":"n8n","status":"failed"},{"name":"session-manager-scheduler","status":"connected"},{"name":"bilko-host","status":"connected"},{"name":"godot","status":"connected"},{"name":"claude.ai Google Calendar","status":"needs-auth"},{"name":"claude.ai Google Drive","status":"needs-auth"},{"name":"claude.ai Gmail","status":"connected"}],"model":"claude-fable-5-1","permissionMode":"bypassPermissions","slash_commands":["refresh-claude-md","deep-research","stripe:explain-error","stripe:test-cards","session-manager-dev:blog-for-project-feature","session-manager-dev:builder","session-manager-dev:develop","session-manager-dev:discover-features","session-manager-dev:explain-to-me","session-manager-dev:find-opportunity","session-manager-dev:issue-address","session-manager-dev:local-project-health","session-manager-dev:memory-sanitation","session-manager-dev:ops-sweep","session-manager-dev:pr-review-sweep","session-manager-dev:pr-signal","session-manager-dev:project-status","session-manager-dev:requesting-code-review","session-manager-dev:send-feedback","stripe:connect-recommend","stripe:connect-required-verification-information","stripe:stripe-apps","stripe:stripe-best-practices","stripe:stripe-directory","stripe:stripe-docs","stripe:stripe-projects","stripe:upgrade-stripe","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator","advisor","agents","auto-mode-setup","autocompact","clear","color","compact","config","context","effort","fast","heapdump","init","mcp","import","model","__remote-workflow","workflow-launch-exec","reload-plugins","reload-skills","rename","ultrareview","security-review","usage-credits","extra-usage","usage","insights","recap","skill-doctor","goal","design","design-consent","design-revoke","list-agents","team-onboarding","mcp__fetch__fetch"],"terminal_slash_commands":["doctor","color","reload-plugins"],"apiKeySource":"none","claude_code_version":"2.1.261","output_style":"default","agents":["architect","bilko-host-publisher","builder","claude","dev-lead","Explore","general-purpose","Plan","project-home-builder","statusline-setup","stripe:Company Researcher"],"skills":["deep-research","session-manager-dev:blog-for-project-feature","session-manager-dev:builder","session-manager-dev:develop","session-manager-dev:discover-features","session-manager-dev:explain-to-me","session-manager-dev:find-opportunity","session-manager-dev:issue-address","session-manager-dev:local-project-health","session-manager-dev:memory-sanitation","session-manager-dev:ops-sweep","session-manager-dev:pr-review-sweep","session-manager-dev:pr-signal","session-manager-dev:project-status","session-manager-dev:requesting-code-review","session-manager-dev:send-feedback","stripe:connect-recommend","stripe:connect-required-verification-information","stripe:stripe-apps","stripe:stripe-best-practices","stripe:stripe-directory","stripe:stripe-docs","stripe:stripe-projects","stripe:upgrade-stripe","design-sync","dataviz","update-config","verify","debug","code-review","simplify","batch","fewer-permission-prompts","doctor","loop","schedule","claude-api","workflow-authoring","run","run-skill-generator"],"plugins":[{"name":"session-manager-dev","path":"/home/bilko/Projects/session-manager/plugins/session-manager-dev","source":"session-manager-dev@session-manager","version":"0.1.0"},{"name":"stripe","path":"/home/bilko/.claude/plugins/cache/claude-plugins-official/stripe/0.6.4","source":"stripe@claude-plugins-official","version":"0.6.4"}],"capabilities":["interrupt_receipt_v1","interrupt_cancel_queued_v1","msg_lifecycle_v1"],"analytics_disabled":false,"product_feedback_disabled":false,"uuid":"7f8e3566-fc40-427c-a9ad-0c0719d7f3b2","memory_paths":{"auto":"/home/bilko/.claude/projects/-home-bilko-Projects-starry-night-ships/memory/"},"messaging_socket_path":"/run/user/1000/cc-socks/3537290.sock","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required"}
|
|
9
|
+
{"type":"rate_limit_event","rate_limit_info":{"status":"rejected","resetsAt":1789059600,"rateLimitType":"seven_day_overage_included","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false,"unifiedWindows":{"five_hour":{"utilization":0,"resetsAt":1788643800},"seven_day":{"utilization":0.59,"resetsAt":1789059600},"seven_day_overage_included":{"utilization":1,"resetsAt":1789059600}}},"uuid":"faa8b505-db69-4afe-9436-9c812b76569a","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b"}
|
|
10
|
+
{"type":"assistant","message":{"diagnostics":null,"id":"dcbde611-72e9-4f07-bc35-c8ec9911f789","container":null,"model":"<synthetic>","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"output_tokens_details":null,"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cli_limit_message, to continue."}],"context_management":null},"parent_tool_use_id":null,"session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","uuid":"a651974e-39c4-440f-a34e-eeec87e65fa4","timestamp":"2026-09-05T16:32:35.275Z","error":"rate_limit","request_id":"req_011CekcYKs4mrLVHXVc5xC2X","is_api_error_message":true}
|
|
11
|
+
{"duration_api_ms":1120,"stop_reason":"stop_sequence","session_id":"5cb9d69b-e2e7-49f9-806c-2307f1c94b2b","total_cost_usd":0.005226,"usage":{"output_tokens_details":{"thinking_tokens":0},"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":5161,"outputTokens":13,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.005226,"contextWindow":200000,"maxOutputTokens":32000,"thinkingTokens":0,"canonicalModel":"claude-haiku-4-5","provider":"firstParty","costBasis":"list"}},"permission_denials":[],"terminal_reason":"api_error","fast_mode_state":"off","fast_mode_disabled_reason":"sdk_opt_in_required","subagent_stats":{"spawned":0,"requested":{"background":0,"foreground":0,"unset":0},"started_in_background":0,"max_depth":0,"spawned_by_subagents":0,"completed":0,"failed":0,"killed":{"parent":0,"user":0,"system":0},"refused":{"depth_limit":0,"concurrency_limit":0,"budget":0},"by_type":{}},"is_error":true,"num_turns":1,"subtype":"success","api_error_status":429,"result":"You've reached your Fable limit. Switch to another model, or manage usage credits at claude.ai/settings/usage?from=cc_cli_limit_message, to continue.","type":"result","duration_ms":1036,"uuid":"b729d2fe-95bd-41a0-9558-eb7149222eb3","queued_turn_count":0}
|
|
12
|
+
|
|
13
|
+
[scheduler] exit code=1 (raw code=1 signal=null) duration=5s
|
|
@@ -13,7 +13,7 @@ const fs = require('node:fs');
|
|
|
13
13
|
const os = require('node:os');
|
|
14
14
|
const path = require('node:path');
|
|
15
15
|
const { execFileSync } = require('node:child_process');
|
|
16
|
-
const { landedSinceRun } = require('../landedSinceRun.cjs');
|
|
16
|
+
const { landedSinceRun, landedOnMainSince } = require('../landedSinceRun.cjs');
|
|
17
17
|
|
|
18
18
|
function mkRepo() {
|
|
19
19
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-since-run-'));
|
|
@@ -23,6 +23,16 @@ function mkRepo() {
|
|
|
23
23
|
return dir;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// -b main pins the branch name regardless of the host's init.defaultBranch
|
|
27
|
+
// config — landedOnMainSince's tests need a deterministic 'main' ref.
|
|
28
|
+
function mkRepoOnMain() {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-on-main-'));
|
|
30
|
+
execFileSync('git', ['-C', dir, 'init', '-q', '-b', 'main']);
|
|
31
|
+
execFileSync('git', ['-C', dir, 'config', 'user.email', 'test@test.com']);
|
|
32
|
+
execFileSync('git', ['-C', dir, 'config', 'user.name', 'Test']);
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
function commitFile(dir, relPath, content, message) {
|
|
27
37
|
const abs = path.join(dir, relPath);
|
|
28
38
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
@@ -71,3 +81,53 @@ test('has a bounded default timeout', () => {
|
|
|
71
81
|
const { LANDED_SINCE_RUN_TIMEOUT_MS } = require('../landedSinceRun.cjs');
|
|
72
82
|
assert.ok(LANDED_SINCE_RUN_TIMEOUT_MS > 0 && LANDED_SINCE_RUN_TIMEOUT_MS <= 60_000);
|
|
73
83
|
});
|
|
84
|
+
|
|
85
|
+
// landedOnMainSince — PRD 1136's already-satisfied-on-main evidence query:
|
|
86
|
+
// scoped to `main` (not --all) and to the PRD's declared paths.
|
|
87
|
+
|
|
88
|
+
test('landedOnMainSince: finds a commit on main after sinceIso touching a declared path', async () => {
|
|
89
|
+
const dir = mkRepoOnMain();
|
|
90
|
+
commitFile(dir, 'README.md', 'hello', 'initial');
|
|
91
|
+
const since = new Date().toISOString();
|
|
92
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
93
|
+
commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
|
|
94
|
+
const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
|
|
95
|
+
assert.strictEqual(shas.length, 1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('landedOnMainSince: ignores a commit that does not touch any declared path', async () => {
|
|
99
|
+
const dir = mkRepoOnMain();
|
|
100
|
+
commitFile(dir, 'README.md', 'hello', 'initial');
|
|
101
|
+
const since = new Date().toISOString();
|
|
102
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
103
|
+
commitFile(dir, 'src/unrelated.js', 'hello', 'touch unrelated');
|
|
104
|
+
const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
|
|
105
|
+
assert.deepStrictEqual(shas, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('landedOnMainSince: a commit older than sinceIso is not evidence', async () => {
|
|
109
|
+
const dir = mkRepoOnMain();
|
|
110
|
+
commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
|
|
111
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
112
|
+
const since = new Date().toISOString();
|
|
113
|
+
const shas = await landedOnMainSince(dir, since, ['src/foo.js']);
|
|
114
|
+
assert.deepStrictEqual(shas, []);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('landedOnMainSince: empty paths never fabricates evidence — resolves []', async () => {
|
|
118
|
+
const dir = mkRepoOnMain();
|
|
119
|
+
commitFile(dir, 'src/foo.js', 'hello', 'fix foo');
|
|
120
|
+
const shas = await landedOnMainSince(dir, new Date(0).toISOString(), []);
|
|
121
|
+
assert.deepStrictEqual(shas, []);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('landedOnMainSince: no cwd resolves [] without throwing', async () => {
|
|
125
|
+
const shas = await landedOnMainSince(null, new Date().toISOString(), ['src/foo.js']);
|
|
126
|
+
assert.deepStrictEqual(shas, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('landedOnMainSince: git-unavailable (non-repo cwd) resolves [] without throwing', async () => {
|
|
130
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'landed-on-main-norepo-'));
|
|
131
|
+
const shas = await landedOnMainSince(dir, new Date().toISOString(), ['src/foo.js']);
|
|
132
|
+
assert.deepStrictEqual(shas, []);
|
|
133
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rateLimitWindow.test.cjs — resolveBindingRateLimitReset: reads the log's
|
|
3
|
+
* OWN rate_limit_event windows and returns the reset of whichever window
|
|
4
|
+
* actually bound (utilization >= 1.0), not the billing endpoint's five_hour
|
|
5
|
+
* assumption (PRD 1118).
|
|
6
|
+
*
|
|
7
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/rateLimitWindow.test.cjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
import { test } from 'vitest';
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
const { resolveBindingRateLimitReset } = require('../rateLimitWindow.cjs');
|
|
18
|
+
|
|
19
|
+
// Repo-tracked, byte-for-byte copy of the real incident log (2026-09-05
|
|
20
|
+
// starry-night-ships 429) so this test is hermetic — CI, a fresh clone, or
|
|
21
|
+
// this machine's own run-log retention sweep (runLogRetention.test.cjs)
|
|
22
|
+
// pruning the live copy must not turn this red for reasons unrelated to the
|
|
23
|
+
// code under test.
|
|
24
|
+
const TRACKED_FIXTURE = path.join(__dirname, 'fixtures', '204-mercury-steam-horse.log.txt');
|
|
25
|
+
const REAL_FIXTURE_LIVE_PATH = '/home/bilko/.claude/session-manager/scheduled-plans/runs/2026-09-05T16-32-18-381Z/204-mercury-steam-horse.log';
|
|
26
|
+
|
|
27
|
+
test('tracked fixture (verbatim copy of the real incident log): seven_day_overage_included (utilization 1.0) binds, not five_hour', () => {
|
|
28
|
+
const reset = resolveBindingRateLimitReset(TRACKED_FIXTURE);
|
|
29
|
+
assert.strictEqual(reset, 1789059600);
|
|
30
|
+
assert.notStrictEqual(reset, 1788643800);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('tracked fixture is byte-identical to the live incident log, when present on this machine', () => {
|
|
34
|
+
if (!fs.existsSync(REAL_FIXTURE_LIVE_PATH)) return; // machine-local artifact, not guaranteed to exist
|
|
35
|
+
assert.strictEqual(
|
|
36
|
+
fs.readFileSync(TRACKED_FIXTURE, 'utf8'),
|
|
37
|
+
fs.readFileSync(REAL_FIXTURE_LIVE_PATH, 'utf8'),
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function writeTmpLog(lines) {
|
|
42
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rate-limit-window-test-'));
|
|
43
|
+
const file = path.join(dir, 'job.log');
|
|
44
|
+
fs.writeFileSync(file, lines.map((l) => (typeof l === 'string' ? l : JSON.stringify(l))).join('\n') + '\n');
|
|
45
|
+
return file;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('no rate_limit_event in log → null', () => {
|
|
49
|
+
const logPath = writeTmpLog(['[scheduler] starting job', '{"type":"system","subtype":"init"}']);
|
|
50
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('rate_limit_event present but no window at/above 1.0 utilization → null', () => {
|
|
54
|
+
const logPath = writeTmpLog([
|
|
55
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
56
|
+
five_hour: { utilization: 0, resetsAt: 1788643800 },
|
|
57
|
+
seven_day: { utilization: 0.59, resetsAt: 1789059600 },
|
|
58
|
+
} } },
|
|
59
|
+
]);
|
|
60
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('multiple windows bind in the last event → the LATEST reset wins', () => {
|
|
64
|
+
const logPath = writeTmpLog([
|
|
65
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
66
|
+
five_hour: { utilization: 1.0, resetsAt: 1000 },
|
|
67
|
+
seven_day: { utilization: 1.0, resetsAt: 2000 },
|
|
68
|
+
} } },
|
|
69
|
+
]);
|
|
70
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), 2000);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('uses the LAST rate_limit_event in the log, not the first', () => {
|
|
74
|
+
const logPath = writeTmpLog([
|
|
75
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
76
|
+
seven_day: { utilization: 0.3, resetsAt: 111 },
|
|
77
|
+
} } },
|
|
78
|
+
{ type: 'other' },
|
|
79
|
+
{ type: 'rate_limit_event', rate_limit_info: { unifiedWindows: {
|
|
80
|
+
seven_day_overage_included: { utilization: 1.0, resetsAt: 999 },
|
|
81
|
+
} } },
|
|
82
|
+
]);
|
|
83
|
+
assert.strictEqual(resolveBindingRateLimitReset(logPath), 999);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('missing file → null (best-effort, matches readTail contract)', () => {
|
|
87
|
+
assert.strictEqual(resolveBindingRateLimitReset('/nonexistent/path/job.log'), null);
|
|
88
|
+
});
|
|
@@ -19,7 +19,12 @@ const assert = require('node:assert/strict');
|
|
|
19
19
|
const fs = require('node:fs');
|
|
20
20
|
const os = require('node:os');
|
|
21
21
|
const path = require('node:path');
|
|
22
|
-
const {
|
|
22
|
+
const { spawn } = require('node:child_process');
|
|
23
|
+
const {
|
|
24
|
+
selectReapableJobs, mapOutcomeToGateOutcome, classifyRunOutcome,
|
|
25
|
+
findLiveProcessForJob, logHasOutput, resolvePidlessGateOutcome,
|
|
26
|
+
isAlreadySatisfiedOnMain,
|
|
27
|
+
} = require('../reaperHelpers.cjs');
|
|
23
28
|
const { detectRateLimitInLog } = require('../rateLimitDetect.cjs');
|
|
24
29
|
|
|
25
30
|
const NOW = Date.parse('2026-09-01T12:00:00.000Z');
|
|
@@ -101,6 +106,33 @@ test('non-running rows are never considered, pidless or not', () => {
|
|
|
101
106
|
assert.deepStrictEqual(warnings, []);
|
|
102
107
|
});
|
|
103
108
|
|
|
109
|
+
test('pidless + past grace + findLiveProcess finds a pid → recovered, never reaped', () => {
|
|
110
|
+
const jobs = [{ slug: 'zombie-alive', status: 'running', startedAt: agoMin(464) }];
|
|
111
|
+
const findLiveProcess = () => 2174739;
|
|
112
|
+
const { reapable, recovered, warnings } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE, findLiveProcess });
|
|
113
|
+
assert.deepStrictEqual(reapable, []);
|
|
114
|
+
assert.deepStrictEqual(warnings, []);
|
|
115
|
+
assert.strictEqual(recovered.length, 1);
|
|
116
|
+
assert.strictEqual(recovered[0].slug, 'zombie-alive');
|
|
117
|
+
assert.strictEqual(recovered[0].pid, 2174739);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('pidless + past grace + findLiveProcess finds nothing → reaped exactly as today', () => {
|
|
121
|
+
const jobs = [{ slug: 'zombie-dead', status: 'running', startedAt: agoMin(464) }];
|
|
122
|
+
const findLiveProcess = () => null;
|
|
123
|
+
const { reapable, recovered } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE, findLiveProcess });
|
|
124
|
+
assert.deepStrictEqual(recovered, []);
|
|
125
|
+
assert.strictEqual(reapable.length, 1);
|
|
126
|
+
assert.strictEqual(reapable[0].pidless, true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('pidless + past grace + no findLiveProcess supplied → reaped exactly as before this PRD', () => {
|
|
130
|
+
const jobs = [{ slug: 'zombie', status: 'running', startedAt: agoMin(464) }];
|
|
131
|
+
const { reapable, recovered } = selectReapableJobs(jobs, NOW, { pidAlive: alwaysAlive, grace: GRACE });
|
|
132
|
+
assert.strictEqual(reapable.length, 1);
|
|
133
|
+
assert.deepStrictEqual(recovered, []);
|
|
134
|
+
});
|
|
135
|
+
|
|
104
136
|
test('mixed batch: each row classified independently', () => {
|
|
105
137
|
const jobs = [
|
|
106
138
|
{ slug: 'zombie', status: 'running', startedAt: agoMin(464) },
|
|
@@ -220,3 +252,90 @@ test('classifyRunOutcome: a clean success log still classifies as success', () =
|
|
|
220
252
|
const p = writeTmpLog('{"type":"result","subtype":"success","is_error":false,"result":"done"}\n');
|
|
221
253
|
assert.strictEqual(classifyRunOutcome(p), 'success');
|
|
222
254
|
});
|
|
255
|
+
|
|
256
|
+
// findLiveProcessForJob — the /proc positive liveness scan behind the
|
|
257
|
+
// 2026-09-06 stranded-branch incident (234-uranus-eight-tails-ox: marked
|
|
258
|
+
// failed/never_ran while its process was alive and still writing to its
|
|
259
|
+
// worktree). Linux-only, and a safe no-op everywhere else per its own header.
|
|
260
|
+
|
|
261
|
+
test('findLiveProcessForJob: finds a live process via /proc/<pid>/cwd match', async () => {
|
|
262
|
+
if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
|
|
263
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reaper-liveness-'));
|
|
264
|
+
const child = spawn('sleep', ['5'], { cwd: dir, stdio: 'ignore' });
|
|
265
|
+
try {
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
267
|
+
const pid = findLiveProcessForJob({ slug: 'irrelevant-slug' }, { worktreeDir: dir });
|
|
268
|
+
assert.strictEqual(pid, child.pid);
|
|
269
|
+
} finally {
|
|
270
|
+
child.kill('SIGKILL');
|
|
271
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('findLiveProcessForJob: no live process anywhere → null', () => {
|
|
276
|
+
if (process.platform !== 'linux' || !fs.existsSync('/proc')) return;
|
|
277
|
+
const pid = findLiveProcessForJob(
|
|
278
|
+
{ slug: 'definitely-not-a-real-running-slug-xyz123' },
|
|
279
|
+
{ worktreeDir: '/nonexistent/worktree/dir/xyz123' },
|
|
280
|
+
);
|
|
281
|
+
assert.strictEqual(pid, null);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// logHasOutput — the literal "did the run dir produce any log output" check
|
|
285
|
+
// that gates whether a pidless reap may assert gateOutcome: 'never_ran'.
|
|
286
|
+
|
|
287
|
+
test('logHasOutput: missing file → false', () => {
|
|
288
|
+
assert.strictEqual(logHasOutput('/nonexistent/path/does-not-exist.log'), false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('logHasOutput: empty file → false', () => {
|
|
292
|
+
const p = writeTmpLog('');
|
|
293
|
+
assert.strictEqual(logHasOutput(p), false);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('logHasOutput: file with content → true', () => {
|
|
297
|
+
const p = writeTmpLog('some real log output\n');
|
|
298
|
+
assert.strictEqual(logHasOutput(p), true);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('logHasOutput: null path → false', () => {
|
|
302
|
+
assert.strictEqual(logHasOutput(null), false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// resolvePidlessGateOutcome — never_ran is asserted ONLY when the run dir has
|
|
306
|
+
// no log output; a run dir WITH output must not claim never_ran even if no
|
|
307
|
+
// clean result event was found in it.
|
|
308
|
+
|
|
309
|
+
test('resolvePidlessGateOutcome: no output → never_ran, regardless of raw outcome (terminalizes as today)', () => {
|
|
310
|
+
assert.strictEqual(resolvePidlessGateOutcome('no_result', false), 'never_ran');
|
|
311
|
+
assert.strictEqual(resolvePidlessGateOutcome('success', false), 'never_ran');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('resolvePidlessGateOutcome: output present + no_result → failed, never never_ran', () => {
|
|
315
|
+
assert.strictEqual(resolvePidlessGateOutcome('no_result', true), 'failed');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('resolvePidlessGateOutcome: output present + success → passed', () => {
|
|
319
|
+
assert.strictEqual(resolvePidlessGateOutcome('success', true), 'passed');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('resolvePidlessGateOutcome: output present + failed → failed', () => {
|
|
323
|
+
assert.strictEqual(resolvePidlessGateOutcome('failed', true), 'failed');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// isAlreadySatisfiedOnMain — PRD 1136. Pure decision layer: given the
|
|
327
|
+
// already-git-queried list of satisfying commits, decide whether the
|
|
328
|
+
// commit-guard's 'silent_no_op' verdict should be overridden to 'completed'.
|
|
329
|
+
|
|
330
|
+
test('isAlreadySatisfiedOnMain: a satisfying commit → completed-shaped verdict naming the sha', () => {
|
|
331
|
+
const verdict = isAlreadySatisfiedOnMain(['5de4134abc123', 'a96de98def456']);
|
|
332
|
+
assert.strictEqual(verdict.sha, '5de4134abc123');
|
|
333
|
+
assert.strictEqual(verdict.verdict, 'already_satisfied_on_main');
|
|
334
|
+
assert.match(verdict.reason, /5de4134abc123/);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('isAlreadySatisfiedOnMain: no satisfying commits → null — the gate must not be weakened into always-passing', () => {
|
|
338
|
+
assert.strictEqual(isAlreadySatisfiedOnMain([]), null);
|
|
339
|
+
assert.strictEqual(isAlreadySatisfiedOnMain(null), null);
|
|
340
|
+
assert.strictEqual(isAlreadySatisfiedOnMain(undefined), null);
|
|
341
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* branchSweep.cjs — periodic recovery for `sm-job/<slug>` branches whose
|
|
3
|
+
* owning job row was terminalized (or lost entirely) without the branch ever
|
|
4
|
+
* being merged back into its project's HEAD (PRD 1135).
|
|
5
|
+
*
|
|
6
|
+
* The reaper's own liveness check (reaperHelpers.cjs findLiveProcessForJob)
|
|
7
|
+
* stops NEW stranding at the source; this module recovers anything already
|
|
8
|
+
* stranded before that fix landed, or by a crash the liveness check can't
|
|
9
|
+
* cover (app killed between a job's commit and its own integrateJobBranch
|
|
10
|
+
* call). Two halves of one invariant — see the PRD body.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately never deletes a branch (`git branch -D` or otherwise) —
|
|
13
|
+
* reclaiming disk is out of scope; this module only ever merges forward.
|
|
14
|
+
*
|
|
15
|
+
* Kept free of scheduler.cjs / electron / queueStore coupling (same reason
|
|
16
|
+
* reaperHelpers.cjs is separate — importable and unit-testable without
|
|
17
|
+
* requiring electron) — callers own persisting a `conflict` result onto a
|
|
18
|
+
* job row.
|
|
19
|
+
*/
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { execFile } = require('node:child_process');
|
|
23
|
+
const gitWorktree = require('./gitWorktree.cjs');
|
|
24
|
+
|
|
25
|
+
function execGit(cwd, args, timeout = 20_000) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
execFile('git', ['-C', cwd, ...args], { timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
|
|
28
|
+
if (err) {
|
|
29
|
+
err.stderrText = stderr;
|
|
30
|
+
reject(err);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
resolve(stdout || '');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** List every local branch under `refs/heads/sm-job/*`. Never throws. */
|
|
39
|
+
async function listSmJobBranches(cwd) {
|
|
40
|
+
try {
|
|
41
|
+
const out = await execGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/sm-job/*']);
|
|
42
|
+
return out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Branch names currently checked out in any linked worktree (incl. main). Never throws. */
|
|
49
|
+
async function listCheckedOutBranches(cwd) {
|
|
50
|
+
try {
|
|
51
|
+
const out = await execGit(cwd, ['worktree', 'list', '--porcelain']);
|
|
52
|
+
return gitWorktree.parseWorktreeListPorcelain(out)
|
|
53
|
+
.map((e) => e.branch)
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TERMINAL_JOB_STATUSES = new Set(['completed', 'failed', 'skipped']);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* sweepStrandedJobBranches({ cwd, jobs, attemptedBranches }) → { results }
|
|
64
|
+
*
|
|
65
|
+
* `jobs` is the full in-memory queue snapshot (any project's rows may be
|
|
66
|
+
* present; matched by slug only, since a slug is globally unique). `attemptedBranches`
|
|
67
|
+
* is a caller-owned `Set` of `"<cwd>::<branch>"` keys — persisted across calls
|
|
68
|
+
* (in-memory is enough: the goal is "not retried every cycle forever" while
|
|
69
|
+
* the process is up, not durability across a restart) so a branch that
|
|
70
|
+
* conflicts once is never retried again in the same process lifetime.
|
|
71
|
+
*
|
|
72
|
+
* Each result is `{ branch, slug, action }` where action is one of:
|
|
73
|
+
* 'skip-checked-out' — branch is checked out in a live worktree, left alone
|
|
74
|
+
* 'no-op-merged' — branch has no commits beyond HEAD, nothing to do
|
|
75
|
+
* 'skip-active-row' — job row exists and is NOT terminal, left alone
|
|
76
|
+
* 'skip-already-attempted' — a prior sweep already tried and failed this branch
|
|
77
|
+
* 'integrated' — merged into HEAD this pass (`integration` attached)
|
|
78
|
+
* 'conflict' — one bounded integrateBranch attempt failed (`integration` attached)
|
|
79
|
+
*
|
|
80
|
+
* SM_BRANCH_SWEEP_DISABLE=1 restores today's behaviour exactly (no-op),
|
|
81
|
+
* mirroring SM_RESUME_RECOVERY_DISABLE.
|
|
82
|
+
*/
|
|
83
|
+
async function sweepStrandedJobBranches({ cwd, jobs, attemptedBranches }) {
|
|
84
|
+
if (process.env.SM_BRANCH_SWEEP_DISABLE === '1') return { results: [] };
|
|
85
|
+
if (!cwd || !(await gitWorktree.isGitRepo(cwd))) return { results: [] };
|
|
86
|
+
|
|
87
|
+
const attempted = attemptedBranches instanceof Set ? attemptedBranches : new Set();
|
|
88
|
+
const branches = await listSmJobBranches(cwd);
|
|
89
|
+
if (!branches.length) return { results: [] };
|
|
90
|
+
const checkedOut = new Set(await listCheckedOutBranches(cwd));
|
|
91
|
+
const jobsBySlug = new Map((Array.isArray(jobs) ? jobs : []).map((j) => [j.slug, j]));
|
|
92
|
+
|
|
93
|
+
const results = [];
|
|
94
|
+
for (const branch of branches) {
|
|
95
|
+
const slug = gitWorktree.keyFromBranch('job', branch);
|
|
96
|
+
if (checkedOut.has(branch)) {
|
|
97
|
+
results.push({ branch, slug, action: 'skip-checked-out' });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const merged = await gitWorktree.isBranchMergedIntoHead(cwd, branch);
|
|
101
|
+
if (merged) {
|
|
102
|
+
results.push({ branch, slug, action: 'no-op-merged' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const row = slug ? jobsBySlug.get(slug) : null;
|
|
106
|
+
const terminalOrMissing = !row || TERMINAL_JOB_STATUSES.has(row.status);
|
|
107
|
+
if (!terminalOrMissing) {
|
|
108
|
+
results.push({ branch, slug, action: 'skip-active-row' });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const attemptKey = `${cwd}::${branch}`;
|
|
112
|
+
if (attempted.has(attemptKey)) {
|
|
113
|
+
results.push({ branch, slug, action: 'skip-already-attempted' });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
attempted.add(attemptKey);
|
|
117
|
+
const integration = await gitWorktree.integrateJobBranch({ cwd, branch, slug: slug || branch });
|
|
118
|
+
results.push({ branch, slug, action: integration.ok ? 'integrated' : 'conflict', integration });
|
|
119
|
+
}
|
|
120
|
+
return { results };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = {
|
|
124
|
+
listSmJobBranches,
|
|
125
|
+
listCheckedOutBranches,
|
|
126
|
+
sweepStrandedJobBranches,
|
|
127
|
+
};
|
|
@@ -859,6 +859,25 @@ async function salvageDirtyDelta({ cwd, paths, outFile }) {
|
|
|
859
859
|
}
|
|
860
860
|
}
|
|
861
861
|
|
|
862
|
+
/**
|
|
863
|
+
* True when every commit on `branch` is already an ancestor of (or equal to)
|
|
864
|
+
* `cwd`'s current HEAD — i.e. the branch holds no work that isn't already
|
|
865
|
+
* landed. Used by the stranded-branch sweep (branchSweep.cjs) to tell a
|
|
866
|
+
* genuinely stranded `sm-job/*` branch from one that's already a no-op.
|
|
867
|
+
* Never throws; a branch that doesn't exist, or any other git failure, is
|
|
868
|
+
* conservatively reported as NOT merged so a caller never treats an
|
|
869
|
+
* unreadable branch as safe to skip.
|
|
870
|
+
*/
|
|
871
|
+
async function isBranchMergedIntoHead(cwd, branch) {
|
|
872
|
+
try {
|
|
873
|
+
const branchHead = (await execGit(['rev-parse', '--verify', branch], { cwd, timeout: 10_000 })).trim();
|
|
874
|
+
const mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
|
|
875
|
+
return mergeBase !== '' && mergeBase === branchHead;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
862
881
|
/** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
|
|
863
882
|
function parseWorktreeListPorcelain(text) {
|
|
864
883
|
const entries = [];
|
|
@@ -1003,6 +1022,7 @@ module.exports = {
|
|
|
1003
1022
|
cleanupWorktree,
|
|
1004
1023
|
salvageWorktreeDiff,
|
|
1005
1024
|
salvageDirtyDelta,
|
|
1025
|
+
isBranchMergedIntoHead,
|
|
1006
1026
|
parseWorktreeListPorcelain,
|
|
1007
1027
|
reconcileWorktreesOnBoot,
|
|
1008
1028
|
sweepStaleWorktreeCheckouts,
|
|
@@ -52,4 +52,44 @@ function landedSinceRun(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIM
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Commits reachable from `main` in `cwd` since `sinceIso` that touch any of
|
|
57
|
+
* `paths` — the evidence query behind PRD 1136's already-satisfied-on-main
|
|
58
|
+
* commit-guard exemption. Deliberately narrower than landedSinceRun in one
|
|
59
|
+
* axis (scoped to `main`, not `--all` — a commit on a stray branch is not
|
|
60
|
+
* "satisfied") and identical in the other (path-scoped, so an unrelated
|
|
61
|
+
* commit elsewhere in the repo is never credited to this job).
|
|
62
|
+
*
|
|
63
|
+
* Falls back from local `main` to `origin/main` when the local branch ref
|
|
64
|
+
* doesn't exist (a worktree/CI checkout that never checked main out
|
|
65
|
+
* locally) — never throws either way; git-unavailable, a non-repo cwd, an
|
|
66
|
+
* unresolvable ref, or an empty `paths` list all resolve to `[]`.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} cwd
|
|
69
|
+
* @param {string} sinceIso
|
|
70
|
+
* @param {string[]} paths
|
|
71
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
72
|
+
* @returns {Promise<string[]>} full commit SHAs, newest first
|
|
73
|
+
*/
|
|
74
|
+
function landedOnMainSince(cwd, sinceIso, paths, { timeoutMs = LANDED_SINCE_RUN_TIMEOUT_MS } = {}) {
|
|
75
|
+
const runLog = (ref) => new Promise((resolve) => {
|
|
76
|
+
execFile(
|
|
77
|
+
'git',
|
|
78
|
+
['-C', cwd, 'log', ref, `--since=${sinceIso}`, '--format=%H', '--', ...paths],
|
|
79
|
+
{ timeout: timeoutMs, windowsHide: true },
|
|
80
|
+
(err, stdout) => {
|
|
81
|
+
if (err) { resolve(null); return; }
|
|
82
|
+
resolve(String(stdout || '').trim().split('\n').filter(Boolean));
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
return (async () => {
|
|
87
|
+
if (!cwd || !sinceIso || !Array.isArray(paths) || paths.length === 0) return [];
|
|
88
|
+
const viaLocal = await runLog('main');
|
|
89
|
+
if (viaLocal !== null) return viaLocal;
|
|
90
|
+
const viaRemote = await runLog('origin/main');
|
|
91
|
+
return viaRemote ?? [];
|
|
92
|
+
})();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { landedSinceRun, landedOnMainSince, LANDED_SINCE_RUN_TIMEOUT_MS };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* rateLimitWindow.cjs — reads the BINDING rate-limit window's reset time
|
|
5
|
+
* straight off a run's own log, instead of trusting the billing usage
|
|
6
|
+
* endpoint's five_hour window (PRD 1118). A run can be 429'd by a
|
|
7
|
+
* different window than five_hour (seven_day, seven_day_overage_included)
|
|
8
|
+
* while five_hour utilization is still 0 — pausing against five_hour's
|
|
9
|
+
* reset in that case arms the resume timer against the wrong clock, and
|
|
10
|
+
* the billing endpoint can itself be 429'ing when this is needed most.
|
|
11
|
+
*
|
|
12
|
+
* Pure: log path in, reset value out. No electron import, so it is
|
|
13
|
+
* unit-testable without scheduler.cjs (mirrors reaperHelpers.cjs's split).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { readTail } = require('./fileTail.cjs');
|
|
17
|
+
|
|
18
|
+
// Authoritative window block rides on the LAST rate_limit_event in the log
|
|
19
|
+
// (often the one attached to the final result), so mirror classifyRunOutcome's
|
|
20
|
+
// 64 KB tail rather than detectRateLimitInLog's smaller 16 KB one.
|
|
21
|
+
const TAIL_BYTES = 64 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Scans a run log's tail for rate_limit_event entries and returns the
|
|
25
|
+
* UNIX-seconds resetsAt of the window that actually bound (utilization >= 1.0)
|
|
26
|
+
* in the LAST such event. When multiple windows bind in that event, the
|
|
27
|
+
* latest resetsAt wins. Returns null when no rate_limit_event is found, or
|
|
28
|
+
* none of its windows are at/above 1.0 utilization.
|
|
29
|
+
*/
|
|
30
|
+
function resolveBindingRateLimitReset(logPath) {
|
|
31
|
+
const text = readTail(logPath, TAIL_BYTES);
|
|
32
|
+
if (!text) return null;
|
|
33
|
+
|
|
34
|
+
let lastWindows = null;
|
|
35
|
+
for (const line of text.split('\n')) {
|
|
36
|
+
const trimmed = line.trim();
|
|
37
|
+
if (!trimmed || trimmed[0] !== '{') continue;
|
|
38
|
+
let event;
|
|
39
|
+
try {
|
|
40
|
+
event = JSON.parse(trimmed);
|
|
41
|
+
} catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const windows = event?.rate_limit_info?.unifiedWindows;
|
|
45
|
+
if (event?.type === 'rate_limit_event' && windows && typeof windows === 'object') {
|
|
46
|
+
lastWindows = windows;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!lastWindows) return null;
|
|
50
|
+
|
|
51
|
+
let latestBoundReset = null;
|
|
52
|
+
for (const window of Object.values(lastWindows)) {
|
|
53
|
+
if (!window || typeof window.utilization !== 'number' || typeof window.resetsAt !== 'number') continue;
|
|
54
|
+
if (window.utilization < 1.0) continue;
|
|
55
|
+
if (latestBoundReset === null || window.resetsAt > latestBoundReset) {
|
|
56
|
+
latestBoundReset = window.resetsAt;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return latestBoundReset;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { resolveBindingRateLimitReset };
|