acstack 0.0.0-stage → 0.47.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/.claude-plugin/plugin.json +19 -0
- package/LICENSE +21 -0
- package/README.md +389 -2
- package/THIRD_PARTY_NOTICES +14 -0
- package/TROUBLESHOOTING.md +225 -0
- package/agents/codex/frontend-verifier.toml +352 -0
- package/agents/codex/task-checker.toml +345 -0
- package/agents/codex/task-executor.toml +157 -0
- package/agents/codex/test-writer.toml +149 -0
- package/agents/frontend-verifier.md +351 -0
- package/agents/task-checker.md +344 -0
- package/agents/task-executor.md +156 -0
- package/agents/test-writer.md +148 -0
- package/assets/playwriter-bridge/build-launcher.ps1 +32 -0
- package/assets/playwriter-bridge/doctor.ps1 +450 -0
- package/assets/playwriter-bridge/launcher-src.cs +131 -0
- package/assets/playwriter-bridge/opener.js +330 -0
- package/assets/playwriter-bridge/opener.ps1 +63 -0
- package/assets/playwriter-bridge/register-tasks.ps1 +328 -0
- package/assets/playwriter-bridge/relay.ps1 +50 -0
- package/assets/playwriter-bridge/stop.ps1 +125 -0
- package/assets/playwriter-bridge/sync.ps1 +271 -0
- package/assets/playwriter-bridge/tunnel.ps1 +325 -0
- package/assets/playwriter-bridge/vm-free-port.sh +166 -0
- package/assets/terminal-workspace/client-keybindings.md +87 -0
- package/assets/terminal-workspace/config.example.yaml +329 -0
- package/assets/terminal-workspace/setup.sh +4317 -0
- package/cli/README.md +47 -0
- package/cli/bin/acstack.js +3 -0
- package/cli/dist/index.js +25984 -0
- package/cli/dist/index.js.map +7 -0
- package/cli/package.json +59 -0
- package/docs/adr/0001-ste-writing-standard.md +16 -0
- package/docs/adr/0002-version-gate-not-auto-bump.md +35 -0
- package/docs/adr/0003-handoff-ack-command.md +19 -0
- package/docs/adr/0004-dev-setup-per-host.md +13 -0
- package/docs/adr/0005-two-orchestrators-per-host.md +25 -0
- package/docs/adr/0006-document-opener-service.md +89 -0
- package/docs/adr/0007-lead-check-reads-the-transcript.md +87 -0
- package/docs/adr/0008-cli-audits-skill-interviews.md +37 -0
- package/docs/adr/0009-verify-the-write-not-the-writer.md +51 -0
- package/docs/adr/0010-promote-dev-to-main-by-fast-forward.md +115 -0
- package/docs/adr/0011-node-header-is-the-edge-encoding.md +20 -0
- package/docs/adr/0012-node-kind-is-a-closed-set-of-five.md +31 -0
- package/docs/adr/0013-the-diagram-renderer-is-pure.md +23 -0
- package/docs/adr/0014-reading-a-node-hides-its-generated-diagram.md +29 -0
- package/docs/adr/0015-a-node-id-links-to-its-issue-through-an-anchor.md +37 -0
- package/docs/adr/0016-the-map-root-carries-no-status-of-its-own.md +21 -0
- package/docs/adr/0017-call-impeccable-copy-only-its-questions.md +13 -0
- package/docs/adr/index.md +17 -0
- package/docs/config-reference.md +572 -0
- package/docs/dev-setups.md +164 -0
- package/docs/migrate-0.8.md +28 -0
- package/docs/playwriter-bridge-windows.md +15 -0
- package/docs/terminal-workspace-keys.md +137 -0
- package/docs/terminal-workspace.md +1774 -0
- package/docs/workflows.md +309 -0
- package/hooks/ac-writing-style-reminder.sh +86 -0
- package/hooks/context-pressure.sh +583 -0
- package/hooks/hooks.json +50 -0
- package/hooks/session-start-handoff.sh +165 -0
- package/hooks/session-start.sh +214 -0
- package/orchestrator/AGENTS.md +532 -0
- package/output-styles/ac-writing-style.md +286 -0
- package/package.json +50 -4
- package/pi/agents/frontend-verifier.md +357 -0
- package/pi/agents/task-checker.md +348 -0
- package/pi/agents/task-executor.md +160 -0
- package/pi/agents/test-writer.md +154 -0
- package/pi/extension.ts +723 -0
- package/profiles/fullstack-python.yaml +29 -0
- package/profiles/node-typescript.yaml +23 -0
- package/profiles/python.yaml +24 -0
- package/skills/archive/SKILL.md +160 -0
- package/skills/bootstrap/SKILL.md +109 -0
- package/skills/bridge/HANDOFF.md +93 -0
- package/skills/bridge/SKILL-VM.md +292 -0
- package/skills/bridge/SKILL-WINDOWS.md +526 -0
- package/skills/bridge/SKILL.md +121 -0
- package/skills/browser-verify/SKILL.md +229 -0
- package/skills/code-review/SKILL.md +44 -0
- package/skills/commit/SKILL.md +106 -0
- package/skills/compact-and-continue/SKILL.md +241 -0
- package/skills/explore/SKILL.md +149 -0
- package/skills/grilling/SKILL.md +134 -0
- package/skills/handoff/SKILL.md +250 -0
- package/skills/handoff/launch-herdr.md +197 -0
- package/skills/handoff/launch-without-herdr.md +30 -0
- package/skills/handoff/lead-check.md +59 -0
- package/skills/handoff/outcomes.md +162 -0
- package/skills/handoff/running-agent.md +50 -0
- package/skills/handoff/watch.md +115 -0
- package/skills/host/SKILL.md +247 -0
- package/skills/host/detect-host.sh +297 -0
- package/skills/host/herdr.md +79 -0
- package/skills/host/install-herdr-block.sh +467 -0
- package/skills/host/install-orca.sh +512 -0
- package/skills/host/notes.md +97 -0
- package/skills/host/orca.md +160 -0
- package/skills/host/questions.md +163 -0
- package/skills/host/report.md +42 -0
- package/skills/implement/SKILL.md +230 -0
- package/skills/implement/decision-log.md +62 -0
- package/skills/implement/design-pass.md +32 -0
- package/skills/implement/relaxed-tdd.md +28 -0
- package/skills/map/SKILL.md +250 -0
- package/skills/map/frontend.md +227 -0
- package/skills/map/trackers/files.md +74 -0
- package/skills/map/trackers/github.md +180 -0
- package/skills/map/visuals.md +387 -0
- package/skills/new-worktree/SKILL.md +155 -0
- package/skills/new-worktree/detect-orchestrator.sh +328 -0
- package/skills/new-worktree/detect-orchestrator.test.sh +894 -0
- package/skills/new-worktree/herdr.md +84 -0
- package/skills/new-worktree/orca.md +101 -0
- package/skills/pre-commit-review/SKILL.md +242 -0
- package/skills/pre-commit-review/auto-fix.md +19 -0
- package/skills/pre-commit-review/interactive.md +35 -0
- package/skills/quick-fix/SKILL.md +125 -0
- package/skills/record-context/SKILL.md +106 -0
- package/skills/setup/SKILL.md +250 -0
- package/skills/setup/browser.md +123 -0
- package/skills/setup/design-review.md +97 -0
- package/skills/setup/first-run.md +114 -0
- package/skills/setup/report.md +68 -0
- package/skills/setup/row-fixes.md +196 -0
- package/skills/setup/terminal-workspace.md +153 -0
- package/skills/sync/SKILL.md +171 -0
- package/skills/teardown-worktree/SKILL.md +245 -0
- package/skills/teardown-worktree/detector.md +39 -0
- package/skills/teardown-worktree/herdr.md +113 -0
- package/skills/teardown-worktree/orca.md +61 -0
- package/skills/terminal-workspace/SKILL.md +249 -0
- package/skills/terminal-workspace/components.md +36 -0
- package/skills/terminal-workspace/config-choices.md +69 -0
- package/skills/terminal-workspace/emulator-keys.md +48 -0
- package/skills/terminal-workspace/headless-clipboard.md +175 -0
- package/skills/terminal-workspace/turn-off-and-update.md +104 -0
- package/skills/test-conventions/SKILL.md +59 -0
- package/skills/visual-docs/SKILL.md +246 -0
- package/skills/writing/SKILL.md +231 -0
- package/skills/writing/examples.md +153 -0
- package/skills/writing/vocabulary.md +96 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# sync.ps1 - Reconcile the deployed Playwriter bridge with the plugin cache.
|
|
2
|
+
#
|
|
3
|
+
# The bridge runs as OS scheduled tasks from %USERPROFILE%\playwriter-bridge\,
|
|
4
|
+
# a location that does not track the plugin version. A plugin auto-update
|
|
5
|
+
# refreshes the cache but never reaches those deployed copies, and a running
|
|
6
|
+
# tunnel.ps1 will not pick up a new script without a restart. This script
|
|
7
|
+
# closes that gap: it is invoked by the ACStack SessionStart hook, compares
|
|
8
|
+
# each deployed script against the cache by content hash, copies the ones that
|
|
9
|
+
# changed, rebuilds launcher.exe when its source changed, and restarts only the
|
|
10
|
+
# affected tasks.
|
|
11
|
+
#
|
|
12
|
+
# It is a quiet no-op unless the bridge is actually installed (deploy dir
|
|
13
|
+
# present AND at least one Playwriter-* task registered), so it costs nothing
|
|
14
|
+
# for sessions and machines that never use the bridge.
|
|
15
|
+
#
|
|
16
|
+
# Runs as the current user, non-elevated - same rights the scheduled tasks use.
|
|
17
|
+
|
|
18
|
+
param(
|
|
19
|
+
[Parameter(Mandatory = $true)]
|
|
20
|
+
[string]$PluginRoot,
|
|
21
|
+
|
|
22
|
+
[Parameter(Mandatory = $false)]
|
|
23
|
+
[string]$ScriptsDir = (Join-Path $env:USERPROFILE "playwriter-bridge")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
$ErrorActionPreference = 'Continue'
|
|
27
|
+
|
|
28
|
+
# Relay, tunnel and launcher only exist on Windows.
|
|
29
|
+
if ($env:OS -ne 'Windows_NT') { exit 0 }
|
|
30
|
+
|
|
31
|
+
$cacheDir = Join-Path $PluginRoot 'assets\playwriter-bridge'
|
|
32
|
+
if (-not (Test-Path $cacheDir)) { exit 0 }
|
|
33
|
+
|
|
34
|
+
# Gate: only act when the bridge is installed.
|
|
35
|
+
if (-not (Test-Path $ScriptsDir)) { exit 0 }
|
|
36
|
+
$tasks = @(Get-ScheduledTask -TaskName 'Playwriter-*' -ErrorAction SilentlyContinue)
|
|
37
|
+
if ($tasks.Count -eq 0) { exit 0 }
|
|
38
|
+
|
|
39
|
+
# Windows-side runtime files. vm-free-port.sh is VM-only; sync.ps1 itself runs
|
|
40
|
+
# from the cache and is never deployed.
|
|
41
|
+
$files = @(
|
|
42
|
+
'launcher-src.cs',
|
|
43
|
+
'build-launcher.ps1',
|
|
44
|
+
'relay.ps1',
|
|
45
|
+
'tunnel.ps1',
|
|
46
|
+
'register-tasks.ps1',
|
|
47
|
+
'stop.ps1',
|
|
48
|
+
'doctor.ps1',
|
|
49
|
+
'opener.ps1',
|
|
50
|
+
'opener.js'
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
function Get-FileHashOrNull($path) {
|
|
54
|
+
if (-not (Test-Path $path)) { return $null }
|
|
55
|
+
return (Get-FileHash -Path $path -Algorithm SHA256 -ErrorAction SilentlyContinue).Hash
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Stop-ScheduledTask returns as soon as the stop is queued, and the scheduler
|
|
59
|
+
# force-terminates launcher.exe rather than letting its ProcessExit handler run,
|
|
60
|
+
# so the launcher's WMI descendant kill never fires. The child PowerShell - and
|
|
61
|
+
# the node.exe opener.ps1/relay.ps1 spawned, or the ssh.exe tunnel.ps1 spawned -
|
|
62
|
+
# survives as an orphan still holding its port. The task started next can then
|
|
63
|
+
# never bind, the new script never actually runs, and this script prints
|
|
64
|
+
# "restarted" anyway. Observed on Windows 11 26200: across a changed opener.js,
|
|
65
|
+
# a Stop/Start pair left the same node.exe PID serving 19989.
|
|
66
|
+
#
|
|
67
|
+
# So: request the stop, wait for the instance to really go, then reap what is
|
|
68
|
+
# left. Scoped by the task's own registered argument string so a tunnel task
|
|
69
|
+
# never reaps its sibling on the other port or the other VM. launcher.exe drops
|
|
70
|
+
# the quotes around -File when it builds the child command line, which is why
|
|
71
|
+
# the comparison is de-quoted on both sides.
|
|
72
|
+
function Stop-BridgeTaskTree {
|
|
73
|
+
param([string]$Name)
|
|
74
|
+
|
|
75
|
+
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
76
|
+
$want = if ($task) { ($task.Actions.Arguments -replace '"', '') } else { $null }
|
|
77
|
+
|
|
78
|
+
Stop-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
|
|
79
|
+
|
|
80
|
+
for ($i = 0; $i -lt 20; $i++) {
|
|
81
|
+
$state = (Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue).State
|
|
82
|
+
if ($state -ne 'Running') { break }
|
|
83
|
+
Start-Sleep -Milliseconds 250
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (-not $want) { return }
|
|
87
|
+
|
|
88
|
+
$survivors = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object {
|
|
89
|
+
$_.ProcessId -ne $PID -and $_.CommandLine -and ($_.CommandLine -replace '"', '') -eq $want
|
|
90
|
+
})
|
|
91
|
+
foreach ($p in $survivors) {
|
|
92
|
+
# Children first. Killing the supervisor alone re-orphans the node.exe
|
|
93
|
+
# or ssh.exe it was holding, which is the whole bug this guards against.
|
|
94
|
+
Get-CimInstance Win32_Process -Filter "ParentProcessId=$($p.ProcessId)" -ErrorAction SilentlyContinue |
|
|
95
|
+
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
|
96
|
+
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
|
97
|
+
Write-Host "acstack bridge: reaped surviving $Name process (PID $($p.ProcessId))"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
$changed = @()
|
|
102
|
+
foreach ($f in $files) {
|
|
103
|
+
$src = Join-Path $cacheDir $f
|
|
104
|
+
if (-not (Test-Path $src)) { continue }
|
|
105
|
+
$dst = Join-Path $ScriptsDir $f
|
|
106
|
+
if ((Get-FileHashOrNull $src) -ne (Get-FileHashOrNull $dst)) { $changed += $f }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if ($changed.Count -eq 0) { exit 0 }
|
|
110
|
+
|
|
111
|
+
Write-Host "acstack bridge: $($changed.Count) script(s) changed in the plugin cache: $($changed -join ', ')"
|
|
112
|
+
|
|
113
|
+
# Concurrency guard: two Claude Code sessions can start near-simultaneously and
|
|
114
|
+
# both pass the hash gate above, then race on copy/rebuild/stop-start. A system
|
|
115
|
+
# mutex serializes the mutating section so only one session reconciles at a time.
|
|
116
|
+
# Creation can require elevation in some environments - if that fails we proceed
|
|
117
|
+
# without the guard rather than crashing the hook.
|
|
118
|
+
$mutex = $null
|
|
119
|
+
try {
|
|
120
|
+
$mutex = New-Object System.Threading.Mutex($false, 'Global\acstack-bridge-sync')
|
|
121
|
+
} catch {
|
|
122
|
+
$mutex = $null
|
|
123
|
+
}
|
|
124
|
+
if ($mutex) {
|
|
125
|
+
$haveLock = $false
|
|
126
|
+
try { $haveLock = $mutex.WaitOne(2000) } catch { $haveLock = $false }
|
|
127
|
+
if (-not $haveLock) {
|
|
128
|
+
Write-Host "acstack bridge: another session is already syncing - skipping."
|
|
129
|
+
$mutex.Dispose()
|
|
130
|
+
exit 0
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Everything from here mutates the deploy dir, so it runs inside try/finally to
|
|
135
|
+
# guarantee the mutex is released on every exit path (including the FIX A early
|
|
136
|
+
# exit below - PowerShell runs finally even when 'exit' fires inside the try).
|
|
137
|
+
try {
|
|
138
|
+
|
|
139
|
+
# Restart eligibility, snapshotted BEFORE we stop anything. A Running task is
|
|
140
|
+
# restarted so the fix goes live. A Ready task is restarted only if it ended on
|
|
141
|
+
# a failure (e.g. tunnel.ps1 exit 11 on a denied key) - that both applies the
|
|
142
|
+
# fix and recovers
|
|
143
|
+
# it. A Ready task that exited cleanly is left alone: the user stopped it on
|
|
144
|
+
# purpose. Benign last-result codes mirror doctor.ps1 - doctor.ps1 holds the
|
|
145
|
+
# canonical copy of this list; keep both in sync if either changes.
|
|
146
|
+
$benign = @(0, 267009, 267011, 267014)
|
|
147
|
+
$eligible = @{}
|
|
148
|
+
foreach ($t in $tasks) {
|
|
149
|
+
$ok = $false
|
|
150
|
+
if ($t.State -eq 'Running') {
|
|
151
|
+
$ok = $true
|
|
152
|
+
} elseif ($t.State -eq 'Ready') {
|
|
153
|
+
$info = Get-ScheduledTaskInfo -TaskName $t.TaskName -ErrorAction SilentlyContinue
|
|
154
|
+
if ($info -and ($benign -notcontains [int]$info.LastTaskResult)) { $ok = $true }
|
|
155
|
+
}
|
|
156
|
+
$eligible[$t.TaskName] = $ok
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
$anyRunning = @($tasks | Where-Object { $_.State -eq 'Running' }).Count -gt 0
|
|
160
|
+
|
|
161
|
+
# A running task holds launcher.exe open, so it cannot be overwritten. When the
|
|
162
|
+
# launcher source changed, stop everything first to release the binary. Stopping
|
|
163
|
+
# is keyed off intent ($changed): stopping is always safe, and we must release
|
|
164
|
+
# the binary BEFORE the copy regardless of whether the copy later succeeds.
|
|
165
|
+
if (($changed -contains 'launcher-src.cs') -and $anyRunning) {
|
|
166
|
+
foreach ($t in $tasks) { Stop-BridgeTaskTree -Name $t.TaskName }
|
|
167
|
+
Start-Sleep -Milliseconds 500
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Copy the changed files into the deploy dir (byte copy preserves the BOM). Track
|
|
171
|
+
# which files ACTUALLY landed in $copied: a locked or permission-denied copy must
|
|
172
|
+
# not silently no-op and then trigger a restart on the stale script (that would
|
|
173
|
+
# re-thrash every session). Restart decisions below derive from $copied, not
|
|
174
|
+
# $changed, so a task whose backing script failed to copy is left alone.
|
|
175
|
+
$copied = @()
|
|
176
|
+
foreach ($f in $changed) {
|
|
177
|
+
try {
|
|
178
|
+
Copy-Item -Path (Join-Path $cacheDir $f) -Destination (Join-Path $ScriptsDir $f) -Force -ErrorAction Stop
|
|
179
|
+
$copied += $f
|
|
180
|
+
} catch {
|
|
181
|
+
Write-Host "acstack bridge: copy FAILED for $f ($($_.Exception.Message)). Leaving its task as-is."
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Decide which tasks each SUCCESSFULLY COPIED file forces a restart of:
|
|
186
|
+
# launcher-src.cs -> every task (all run through launcher.exe)
|
|
187
|
+
# relay.ps1 -> Playwriter-Relay
|
|
188
|
+
# opener.ps1 -> Playwriter-Opener
|
|
189
|
+
# opener.js -> Playwriter-Opener (opener.ps1 supervises it, so the
|
|
190
|
+
# supervisor has to be bounced for the new code to run)
|
|
191
|
+
# tunnel.ps1 -> every tunnel task, bridge and opener alike
|
|
192
|
+
# stop.ps1 / doctor.ps1 / build-launcher.ps1 / register-tasks.ps1 are invoked
|
|
193
|
+
# on demand, so a copy is enough - no restart.
|
|
194
|
+
# NOTE: any NEW task-backing script added to $files above must also be wired into
|
|
195
|
+
# this mapping, or it will deploy but never trigger a restart of its task.
|
|
196
|
+
$launcherChanged = $copied -contains 'launcher-src.cs'
|
|
197
|
+
$relayChanged = $copied -contains 'relay.ps1'
|
|
198
|
+
$tunnelChanged = $copied -contains 'tunnel.ps1'
|
|
199
|
+
$openerChanged = ($copied -contains 'opener.ps1') -or ($copied -contains 'opener.js')
|
|
200
|
+
|
|
201
|
+
$affected = New-Object System.Collections.Generic.HashSet[string]
|
|
202
|
+
if ($launcherChanged) {
|
|
203
|
+
foreach ($t in $tasks) { [void]$affected.Add($t.TaskName) }
|
|
204
|
+
} else {
|
|
205
|
+
if ($relayChanged) { [void]$affected.Add('Playwriter-Relay') }
|
|
206
|
+
if ($openerChanged) { [void]$affected.Add('Playwriter-Opener') }
|
|
207
|
+
if ($tunnelChanged) {
|
|
208
|
+
# Both families run tunnel.ps1. 'Playwriter-Tunnel-*' alone would miss
|
|
209
|
+
# the opener's tunnel tasks, which are named Playwriter-OpenerTunnel-*.
|
|
210
|
+
foreach ($t in $tasks) {
|
|
211
|
+
if ($t.TaskName -like 'Playwriter-Tunnel-*' -or $t.TaskName -like 'Playwriter-OpenerTunnel-*') {
|
|
212
|
+
[void]$affected.Add($t.TaskName)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
$restartList = @($affected | Where-Object { $eligible[$_] })
|
|
219
|
+
|
|
220
|
+
# Rebuild launcher.exe from the freshly copied source. $rebuildOk starts true
|
|
221
|
+
# (true when no rebuild was needed); the catch and a missing output both clear
|
|
222
|
+
# it. If launcher-src.cs failed to copy, $launcherChanged is already false, so
|
|
223
|
+
# no rebuild is attempted and the source is stale - that is also a
|
|
224
|
+
# rebuild-can't-happen case, so we force $rebuildOk false to skip the restart.
|
|
225
|
+
$rebuildOk = $true
|
|
226
|
+
if ($launcherChanged) {
|
|
227
|
+
try {
|
|
228
|
+
& (Join-Path $ScriptsDir 'build-launcher.ps1') | Out-Null
|
|
229
|
+
if (Test-Path (Join-Path $ScriptsDir 'launcher.exe')) {
|
|
230
|
+
Write-Host "acstack bridge: rebuilt launcher.exe"
|
|
231
|
+
} else {
|
|
232
|
+
$rebuildOk = $false
|
|
233
|
+
Write-Host "acstack bridge: launcher rebuild produced no launcher.exe."
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
$rebuildOk = $false
|
|
237
|
+
Write-Host "acstack bridge: launcher rebuild FAILED ($($_.Exception.Message))."
|
|
238
|
+
}
|
|
239
|
+
} elseif (($changed -contains 'launcher-src.cs') -and -not ($copied -contains 'launcher-src.cs')) {
|
|
240
|
+
# launcher-src.cs changed but the copy failed: the deployed source is stale
|
|
241
|
+
# and launcher.exe cannot be trusted to match it, so do not restart anything.
|
|
242
|
+
$rebuildOk = $false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# If the rebuild did not produce a good launcher.exe, the tasks were already
|
|
246
|
+
# stopped above to release the binary. Restarting any of them now would run them
|
|
247
|
+
# against a broken or missing exe and leave the bridge down. Skip the restart
|
|
248
|
+
# loop, surface a loud actionable message, and leave the tasks stopped.
|
|
249
|
+
if (-not $rebuildOk) {
|
|
250
|
+
Write-Host "acstack bridge: launcher.exe is NOT in a known-good state - skipping all task restarts. Run build-launcher.ps1 manually, then start the Playwriter-* tasks."
|
|
251
|
+
exit 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Restart the affected, eligible tasks. Stop is a no-op if a task is already
|
|
255
|
+
# stopped (e.g. it was stopped above for the launcher rebuild, or it had failed).
|
|
256
|
+
foreach ($name in $restartList) {
|
|
257
|
+
Stop-BridgeTaskTree -Name $name
|
|
258
|
+
Start-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
|
|
259
|
+
Write-Host "acstack bridge: restarted $name"
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
exit 0
|
|
263
|
+
|
|
264
|
+
} finally {
|
|
265
|
+
# Release the mutex on every exit path. Runs even when 'exit' fires inside the
|
|
266
|
+
# try above (FIX A early exit, the normal exit 0, or an uncaught throw).
|
|
267
|
+
if ($mutex) {
|
|
268
|
+
try { $mutex.ReleaseMutex() } catch { }
|
|
269
|
+
$mutex.Dispose()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# tunnel.ps1 – Reverse SSH tunnel with exponential backoff and fatal-error
|
|
2
|
+
# classification.
|
|
3
|
+
#
|
|
4
|
+
# Opens `ssh -N -R 19988:127.0.0.1:19988 <user>@<host>` so the VM can reach
|
|
5
|
+
# the local Playwriter relay at 127.0.0.1:19988. Intended to be invoked by a
|
|
6
|
+
# scheduled task registered by register-tasks.ps1. Logs to
|
|
7
|
+
# %LOCALAPPDATA%\playwriter-bridge\tunnel-<host>.log.
|
|
8
|
+
#
|
|
9
|
+
# ExitOnForwardFailure=yes is load-bearing: if the remote side already has
|
|
10
|
+
# something bound to 19988 (stale forward), ssh would otherwise report
|
|
11
|
+
# success without the forward and the bridge would silently not work.
|
|
12
|
+
#
|
|
13
|
+
# Retry policy:
|
|
14
|
+
# - Transient errors (connection refused / timed out / no route / TCP reset
|
|
15
|
+
# under a live session / Win32 connect "Unknown error") are retried with
|
|
16
|
+
# exponential backoff 5 → 10 → 20 → 40 → 80 → 120 s cap.
|
|
17
|
+
# - Successful bind (process ran at least 30 s) resets the backoff.
|
|
18
|
+
# - "remote port forwarding failed" is retried for as long as it takes, and
|
|
19
|
+
# never surrendered. It clears once the VM's sshd reaps the orphaned
|
|
20
|
+
# forward channel left by the dropped session (ClientAliveInterval ×
|
|
21
|
+
# ClientAliveCountMax – ~90 s on a VM configured per the runbook, up to
|
|
22
|
+
# ~360 s on a default/cloud-image VM), and a holder that outlasts that is
|
|
23
|
+
# still a port that comes back the moment someone frees it. The wait grows
|
|
24
|
+
# 30 → 60 → 120 s and caps there, so a long wedge costs one attempt every
|
|
25
|
+
# two minutes rather than a hammered port. After 8 consecutive stuck
|
|
26
|
+
# attempts (~11 min) the port is held by something the reaper is not going
|
|
27
|
+
# to release, so a stuck marker goes down for doctor.ps1 to report – but the
|
|
28
|
+
# retries carry on underneath it, and the marker is cleared as soon as a
|
|
29
|
+
# forward stays bound for 30 s, observed live rather than after ssh exits.
|
|
30
|
+
# - "Permission denied" or "Host key verification failed" exit immediately
|
|
31
|
+
# with a marker. These require user action.
|
|
32
|
+
#
|
|
33
|
+
# Marker file: tunnel-<host>.stuck next to the log. Contains the reason,
|
|
34
|
+
# timestamp, and the last 20 log lines. doctor.ps1 reads this.
|
|
35
|
+
#
|
|
36
|
+
# Port 19988 is the bridge's canonical CDP port and is the default here.
|
|
37
|
+
# Port 19989 carries the document opener, registered as a second task
|
|
38
|
+
# instance of this same script. 19988 is hardcoded across
|
|
39
|
+
# the setup. If you ever need to change it, update every occurrence in:
|
|
40
|
+
# assets/playwriter-bridge/{tunnel.ps1, relay.ps1, stop.ps1, doctor.ps1,
|
|
41
|
+
# register-tasks.ps1, vm-free-port.sh}
|
|
42
|
+
# agents/frontend-verifier.md, skills/browser-verify/SKILL.md,
|
|
43
|
+
# skills/bridge/{SKILL.md, SKILL-WINDOWS.md, SKILL-VM.md, HANDOFF.md}
|
|
44
|
+
# The relay binds it via `playwriter serve`'s default; the rest reference
|
|
45
|
+
# it literally.
|
|
46
|
+
|
|
47
|
+
param(
|
|
48
|
+
[Parameter(Mandatory = $true)]
|
|
49
|
+
[string]$TargetHost,
|
|
50
|
+
|
|
51
|
+
[Parameter(Mandatory = $false)]
|
|
52
|
+
[string]$User = $env:USERNAME,
|
|
53
|
+
|
|
54
|
+
# Which port to carry. 19988 is the bridge's CDP port; 19989 is the
|
|
55
|
+
# document opener. They are forwarded by separate task instances rather
|
|
56
|
+
# than as two -R flags on one connection, deliberately: with
|
|
57
|
+
# ExitOnForwardFailure=yes a single wedged port fails the whole ssh, so
|
|
58
|
+
# sharing a connection would let a stuck opener port take the bridge down
|
|
59
|
+
# with it. Separate connections keep the two failures independent.
|
|
60
|
+
[Parameter(Mandatory = $false)]
|
|
61
|
+
[int]$Port = 19988
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Console hiding is handled by launcher.exe (spawns PowerShell with
|
|
65
|
+
# CREATE_NO_WINDOW). Nothing to do here.
|
|
66
|
+
|
|
67
|
+
$ErrorActionPreference = "Continue"
|
|
68
|
+
|
|
69
|
+
$logDir = Join-Path $env:LOCALAPPDATA "playwriter-bridge"
|
|
70
|
+
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
|
|
71
|
+
$logSuffix = if ($Port -eq 19988) { "" } else { "-$Port" }
|
|
72
|
+
$logFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.log"
|
|
73
|
+
$markerFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.stuck"
|
|
74
|
+
# ssh is started as a child we can watch rather than run inline, so its streams
|
|
75
|
+
# have to land somewhere we can read after it exits. Two files: PowerShell 5.1
|
|
76
|
+
# refuses to redirect both streams to one.
|
|
77
|
+
$errFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.err"
|
|
78
|
+
$outFile = Join-Path $logDir "tunnel-$TargetHost$logSuffix.out"
|
|
79
|
+
|
|
80
|
+
# The stuck marker is NOT cleared on start. This loop never exits on a wedged
|
|
81
|
+
# port, but the task can still be restarted underneath it, and clearing on
|
|
82
|
+
# start would let doctor.ps1 sample green during a still-wedged restart. The
|
|
83
|
+
# marker reflects "still needs attention until a real bind succeeds" – it is
|
|
84
|
+
# cleared only after a successful bind (see the proof-of-life path below).
|
|
85
|
+
|
|
86
|
+
function Write-Log {
|
|
87
|
+
param(
|
|
88
|
+
[string]$State,
|
|
89
|
+
[int]$ExitCode,
|
|
90
|
+
[string]$Message
|
|
91
|
+
)
|
|
92
|
+
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
|
93
|
+
"[$ts] [$State] [$ExitCode] $Message" | Out-File -Append -FilePath $logFile -Encoding utf8
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Rotate-Log {
|
|
97
|
+
if (-not (Test-Path $logFile)) { return }
|
|
98
|
+
$size = (Get-Item $logFile).Length
|
|
99
|
+
if ($size -lt 1MB) { return }
|
|
100
|
+
$prior = "$logFile.1"
|
|
101
|
+
Remove-Item $prior -ErrorAction SilentlyContinue
|
|
102
|
+
Move-Item $logFile $prior -ErrorAction SilentlyContinue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Write-Marker {
|
|
106
|
+
param(
|
|
107
|
+
[string]$Reason,
|
|
108
|
+
[string]$Remediation
|
|
109
|
+
)
|
|
110
|
+
$tail = @()
|
|
111
|
+
if (Test-Path $logFile) {
|
|
112
|
+
$tail = Get-Content $logFile -Tail 20
|
|
113
|
+
}
|
|
114
|
+
$content = @()
|
|
115
|
+
$content += "timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
|
116
|
+
$content += "target: $remote"
|
|
117
|
+
$content += "reason: $Reason"
|
|
118
|
+
$content += "remediation: $Remediation"
|
|
119
|
+
$content += ""
|
|
120
|
+
$content += "--- last 20 log lines ---"
|
|
121
|
+
$content += $tail
|
|
122
|
+
$content | Out-File -FilePath $markerFile -Encoding utf8
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function Classify-Output {
|
|
126
|
+
param([string[]]$Lines)
|
|
127
|
+
$joined = ($Lines -join "`n")
|
|
128
|
+
if ($joined -match 'remote port forwarding failed for listen port') {
|
|
129
|
+
return 'stuck-port'
|
|
130
|
+
}
|
|
131
|
+
if ($joined -match 'Permission denied \(publickey') {
|
|
132
|
+
return 'auth-denied'
|
|
133
|
+
}
|
|
134
|
+
if ($joined -match 'Host key verification failed') {
|
|
135
|
+
return 'host-key'
|
|
136
|
+
}
|
|
137
|
+
# 'Connection reset' / 'client_loop: send disconnect' is OpenSSH's message
|
|
138
|
+
# when the TCP socket of a live session is reset under it (wifi roam, VPN
|
|
139
|
+
# flap, suspend/resume). 'port <n>: Unknown error' is Win32-OpenSSH's
|
|
140
|
+
# WSAEHOSTUNREACH/WSAENETUNREACH on connect. Both are recoverable network
|
|
141
|
+
# events – classify transient so they back off and reconnect rather than
|
|
142
|
+
# falling through to 'unknown'.
|
|
143
|
+
if ($joined -match 'Connection refused|Connection timed out|Connection reset|client_loop: send disconnect|No route to host|Network is unreachable|Could not resolve hostname|port \d+: Unknown error') {
|
|
144
|
+
return 'transient'
|
|
145
|
+
}
|
|
146
|
+
return 'unknown'
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
$sshExe = Join-Path $env:WINDIR "System32\OpenSSH\ssh.exe"
|
|
150
|
+
if (-not (Test-Path $sshExe)) {
|
|
151
|
+
$onPath = Get-Command ssh -ErrorAction SilentlyContinue
|
|
152
|
+
if ($onPath) { $sshExe = $onPath.Source }
|
|
153
|
+
else { throw "ssh.exe not found. Enable the Windows OpenSSH Client optional feature." }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
$remote = "$User@$TargetHost"
|
|
157
|
+
|
|
158
|
+
$backoff = @(5, 10, 20, 40, 80, 120)
|
|
159
|
+
$backoffIndex = 0
|
|
160
|
+
$stuckStreak = 0
|
|
161
|
+
$startFailStreak = 0
|
|
162
|
+
|
|
163
|
+
# Stuck-port retry policy. There is no budget: giving up hands recovery to the
|
|
164
|
+
# Scheduler backstop, which on a wedged port is a restart loop nobody watches,
|
|
165
|
+
# and the bridge is simply down between the exit and the restart. So the loop
|
|
166
|
+
# outlasts the wedge instead. The first wait is sized to the reap window and
|
|
167
|
+
# then doubles to a 120 s cap – the same ceiling the transient path uses – so
|
|
168
|
+
# a port held for hours costs one attempt every two minutes.
|
|
169
|
+
$stuckRetrySeconds = 30
|
|
170
|
+
$stuckMaxRetrySeconds = 120
|
|
171
|
+
$stuckWait = $stuckRetrySeconds
|
|
172
|
+
# How many consecutive stuck attempts before the port counts as genuinely held
|
|
173
|
+
# rather than mid-reap. The waits before the 8th attempt are
|
|
174
|
+
# 30 + 60 + 5 × 120 = 690 s, so the marker lands about 11 min in. That outlasts
|
|
175
|
+
# both a tightened (~90 s) and a default cloud-image (~360 s) reap window, so a
|
|
176
|
+
# marker written past it means a non-sshd holder or keepalive disabled –
|
|
177
|
+
# something a human has to clear. Retune this if the backoff above changes:
|
|
178
|
+
# with the doubling wait, a streak of 20 would be ~35 min, far too quiet.
|
|
179
|
+
$stuckMaxStreak = 8
|
|
180
|
+
|
|
181
|
+
while ($true) {
|
|
182
|
+
Rotate-Log
|
|
183
|
+
Write-Log -State 'start' -ExitCode 0 -Message "Starting tunnel to $remote"
|
|
184
|
+
|
|
185
|
+
Remove-Item $errFile -ErrorAction SilentlyContinue
|
|
186
|
+
Remove-Item $outFile -ErrorAction SilentlyContinue
|
|
187
|
+
$sshArgs = @(
|
|
188
|
+
'-N',
|
|
189
|
+
'-R', "$($Port):127.0.0.1:$Port",
|
|
190
|
+
'-o', 'ServerAliveInterval=30',
|
|
191
|
+
'-o', 'ServerAliveCountMax=3',
|
|
192
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
193
|
+
'-o', 'BatchMode=yes',
|
|
194
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
195
|
+
$remote
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
$startedAt = Get-Date
|
|
199
|
+
$proc = $null
|
|
200
|
+
try {
|
|
201
|
+
$proc = Start-Process -FilePath $sshExe -ArgumentList $sshArgs -NoNewWindow -PassThru `
|
|
202
|
+
-RedirectStandardError $errFile -RedirectStandardOutput $outFile
|
|
203
|
+
# Touching .Handle caches the native process handle in this object. Without
|
|
204
|
+
# -Wait, Start-Process does not hold one open, so Windows releases the
|
|
205
|
+
# handle the moment ssh exits and $proc.ExitCode below reads $null - which
|
|
206
|
+
# every log line then renders as [] or, through Write-Log's [int] parameter,
|
|
207
|
+
# as a flat [0]. Every ssh failure reported success. WaitForExit() does not
|
|
208
|
+
# rescue it; by then the handle is already gone.
|
|
209
|
+
$null = $proc.Handle
|
|
210
|
+
} catch {
|
|
211
|
+
Write-Log -State 'error' -ExitCode 1 -Message "Could not start ${sshExe}: $($_.Exception.Message)"
|
|
212
|
+
}
|
|
213
|
+
if (-not $proc) {
|
|
214
|
+
# A start that never happened is a tunnel that is down, and it used to be
|
|
215
|
+
# a silent one: the catch above logged 'error', backed off, and looped
|
|
216
|
+
# forever with no marker and no state word doctor.ps1 scans for, so the
|
|
217
|
+
# report stayed green on a tunnel that had never run. An orphaned ssh
|
|
218
|
+
# still holding tunnel-<host>.err is enough to cause it - Remove-Item
|
|
219
|
+
# fails, the redirect cannot open, and Start-Process throws.
|
|
220
|
+
$startFailStreak++
|
|
221
|
+
$startWait = $backoff[[Math]::Min($backoffIndex, $backoff.Length - 1)]
|
|
222
|
+
Write-Log -State 'start-failed' -ExitCode 1 -Message "ssh did not start for $remote (attempt $startFailStreak); retrying in ${startWait}s."
|
|
223
|
+
if ($startFailStreak -ge $stuckMaxStreak) {
|
|
224
|
+
# Same threshold as a wedged port, for the same reason: past it,
|
|
225
|
+
# this is not going to clear without a human.
|
|
226
|
+
Write-Marker -Reason "ssh could not be started for $remote after $startFailStreak consecutive attempts" `
|
|
227
|
+
-Remediation "Look in %LOCALAPPDATA%\playwriter-bridge for an orphaned ssh.exe holding tunnel-$TargetHost$logSuffix.err or .out, and check free disk space. Kill any stray ssh.exe carrying the -R $Port forward. This task keeps retrying, so it recovers on its own once the file is free."
|
|
228
|
+
}
|
|
229
|
+
Start-Sleep -Seconds $startWait
|
|
230
|
+
if ($backoffIndex -lt $backoff.Length - 1) { $backoffIndex++ }
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
$startFailStreak = 0
|
|
234
|
+
|
|
235
|
+
# Proof of life, taken while the tunnel is still up rather than after it
|
|
236
|
+
# falls over. ExitOnForwardFailure=yes makes ssh die at once when the remote
|
|
237
|
+
# port will not bind, so a child still running at 30 s IS a bound forward.
|
|
238
|
+
# Waiting for the exit to notice that was the bug: a healthy tunnel stays up
|
|
239
|
+
# for days, and until it dropped, a stuck marker from an earlier wedge sat
|
|
240
|
+
# there with doctor.ps1 reporting red on a bridge that had worked all along.
|
|
241
|
+
$provenAlive = $false
|
|
242
|
+
while (-not $proc.HasExited) {
|
|
243
|
+
Start-Sleep -Seconds 1
|
|
244
|
+
if (-not $provenAlive -and ((Get-Date) - $startedAt).TotalSeconds -ge 30) {
|
|
245
|
+
$provenAlive = $true
|
|
246
|
+
$stuckStreak = 0
|
|
247
|
+
$stuckWait = $stuckRetrySeconds
|
|
248
|
+
$backoffIndex = 0
|
|
249
|
+
Remove-Item $markerFile -ErrorAction SilentlyContinue
|
|
250
|
+
Write-Log -State 'up' -ExitCode 0 -Message "Forward for port $Port bound to $remote and up for 30s. Backoff reset, any stuck marker cleared."
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
$proc.WaitForExit()
|
|
254
|
+
$code = $proc.ExitCode
|
|
255
|
+
$ranSeconds = ((Get-Date) - $startedAt).TotalSeconds
|
|
256
|
+
|
|
257
|
+
$output = @()
|
|
258
|
+
foreach ($streamFile in @($errFile, $outFile)) {
|
|
259
|
+
if (Test-Path $streamFile) { $output += @(Get-Content $streamFile -ErrorAction SilentlyContinue) }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
$output | ForEach-Object { "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [ssh-stderr] [$code] $_" } |
|
|
263
|
+
Out-File -Append -FilePath $logFile -Encoding utf8
|
|
264
|
+
|
|
265
|
+
$class = Classify-Output -Lines $output
|
|
266
|
+
Write-Log -State 'exit' -ExitCode $code -Message "Tunnel to $remote exited after $([int]$ranSeconds)s, class=$class"
|
|
267
|
+
|
|
268
|
+
switch ($class) {
|
|
269
|
+
'stuck-port' {
|
|
270
|
+
$stuckStreak++
|
|
271
|
+
if ($stuckStreak -ge $stuckMaxStreak) {
|
|
272
|
+
# Past the reap window, so this is a holder the VM will not let
|
|
273
|
+
# go of on its own. Say so where doctor.ps1 can read it – and
|
|
274
|
+
# keep retrying, because the marker is a report, not a verdict:
|
|
275
|
+
# the moment someone frees the port, the next attempt binds.
|
|
276
|
+
Write-Log -State 'stuck' -ExitCode $code -Message "Port $Port still bound on $TargetHost after $stuckStreak consecutive attempts. Marker written; still retrying every ${stuckWait}s."
|
|
277
|
+
Write-Marker -Reason "remote port forwarding failed – port $Port still bound on VM after $stuckStreak consecutive attempts (the VM's sshd has not reaped the orphaned forward channel)" `
|
|
278
|
+
-Remediation "Run ~/.claude/acstack/bin/vm-free-port.sh --port $Port on $TargetHost. This task is still retrying, so it will bind on its own once the port is free – no restart needed. If this recurs, tighten the VM's sshd ClientAliveInterval per the bridge SKILL-VM runbook so orphaned forwards reap faster."
|
|
279
|
+
}
|
|
280
|
+
# The port is held by the orphaned forward channel of the dropped
|
|
281
|
+
# session. Wait for the VM's sshd ClientAlive reaper to release it;
|
|
282
|
+
# the next attempt then binds. This is the bridge's self-heal path,
|
|
283
|
+
# so the wait is sized to outlast the reap window, not a token 10 s,
|
|
284
|
+
# and doubles to the cap so a long wedge is not hammered.
|
|
285
|
+
Write-Log -State 'stuck-retry' -ExitCode $code -Message "Port $Port still held on $TargetHost (attempt $stuckStreak); waiting ${stuckWait}s for the VM to reap the orphaned forward."
|
|
286
|
+
Start-Sleep -Seconds $stuckWait
|
|
287
|
+
$stuckWait = [Math]::Min($stuckWait * 2, $stuckMaxRetrySeconds)
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
'auth-denied' {
|
|
291
|
+
Write-Log -State 'fatal' -ExitCode $code -Message "Permission denied. Writing marker and exiting."
|
|
292
|
+
Write-Marker -Reason 'Permission denied (publickey)' `
|
|
293
|
+
-Remediation "Run 'ssh-add' on Windows and confirm the matching public key is in ~/.ssh/authorized_keys on $TargetHost."
|
|
294
|
+
exit 11
|
|
295
|
+
}
|
|
296
|
+
'host-key' {
|
|
297
|
+
Write-Log -State 'fatal' -ExitCode $code -Message "Host key verification failed. Writing marker and exiting."
|
|
298
|
+
Write-Marker -Reason 'Host key verification failed' `
|
|
299
|
+
-Remediation "Resolve the host key mismatch for $TargetHost before retrying."
|
|
300
|
+
exit 12
|
|
301
|
+
}
|
|
302
|
+
default {
|
|
303
|
+
# Transient or unknown – apply backoff. Reset if the previous
|
|
304
|
+
# run lasted at least 30 s (implies the forward was bound and
|
|
305
|
+
# the session stayed up).
|
|
306
|
+
# A short transient blip must NOT zero the stuck-streak – only a
|
|
307
|
+
# successful bind does, so the give-up budget can't be starved by
|
|
308
|
+
# a single brief failure between stuck-port retries.
|
|
309
|
+
# Backstop for a run the poll above never got to observe – a
|
|
310
|
+
# suspend/resume can swallow its one-second ticks. On any normal
|
|
311
|
+
# long run $provenAlive already did all of this; repeating it is
|
|
312
|
+
# idempotent, and a run this long bound its forward either way.
|
|
313
|
+
if ($ranSeconds -ge 30) {
|
|
314
|
+
$stuckStreak = 0
|
|
315
|
+
$stuckWait = $stuckRetrySeconds
|
|
316
|
+
$backoffIndex = 0
|
|
317
|
+
Remove-Item $markerFile -ErrorAction SilentlyContinue
|
|
318
|
+
}
|
|
319
|
+
$wait = $backoff[[Math]::Min($backoffIndex, $backoff.Length - 1)]
|
|
320
|
+
Write-Log -State 'retry' -ExitCode $code -Message "Sleeping ${wait}s (class=$class, lasted $([int]$ranSeconds)s)"
|
|
321
|
+
Start-Sleep -Seconds $wait
|
|
322
|
+
if ($backoffIndex -lt $backoff.Length - 1) { $backoffIndex++ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|