@twinsearth/w2m-dsh-plugin 0.5.8 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,7 +28,15 @@ It registers **one** task that runs `w2m-agent-supervisor.mjs`, which owns the a
28
28
 
29
29
  * `-AtStartup` **and** `-AtLogOn`, `LogonType S4U` → runs whether the user is signed in or not,
30
30
  in session 0, with no console for anything to close;
31
- * `MultipleInstances IgnoreNew` → two agents must never share one device identity;
31
+ * a **watchdog trigger** every 5 minutes (`-WatchdogMinutes`), because a supervisor killed by a
32
+ console teardown cannot be restarted by `RestartCount` alone — the task simply ends;
33
+ * `MultipleInstances Parallel` → the trigger may start a second supervisor, which is safe because
34
+ the supervisor's own lock is what prevents a second *agent*: a generation that finds the lock held
35
+ logs a no-op line and exits without spawning. (This replaced `IgnoreNew`, which could not recover
36
+ from the one death that matters: measured, a task whose supervisor died while the task still
37
+ reported `Running` refused every later start with `0x800710E0` and stayed dead — one supervisor,
38
+ one agent, the port bound, and no way back. Re-register an existing installation with
39
+ `-UpdateTask`, which needs no elevation and touches only the triggers and settings.)
32
40
  * `RestartCount 999` / 1 min, `ExecutionTimeLimit 0`, `-StartWhenAvailable`.
33
41
 
34
42
  Everything except the task's own command line goes into **`%USERPROFILE%\.dsh\w2m\agent.json`**,
@@ -101,11 +109,22 @@ back to the relay with `offer_path: "relay"` in the ledger.
101
109
 
102
110
  ## Verify it, rather than trusting it
103
111
 
112
+ The supervisor's invariants — one generation, one agent, the punch port bound by that agent — have a
113
+ runnable check that needs no administrator right and cannot disturb a live installation (it drives
114
+ the supervisor against a stub worker on ports the OS picks):
115
+
116
+ ```powershell
117
+ node deploy\windows\verify-supervisor.mjs # 8 checks; --keep leaves the temp tree behind
118
+ ```
119
+
104
120
  ```powershell
105
121
  # the task is running, and in which session
106
122
  Get-ScheduledTask -TaskName 'W2M Localside agent' | Select-Object State
107
123
  Get-Process node | Where-Object { $_.SessionId -eq 0 } | Select-Object Id, SessionId, StartTime
108
124
 
125
+ # the supervisor's own account of itself: state, the live child, the reaped pids, the punch port
126
+ Get-Content "$env:USERPROFILE\.dsh\w2m\supervisor-heartbeat.json"
127
+
109
128
  # the agent's own narration: "stream ready" is the line that means the fleet can reach it
110
129
  Get-Content "$env:USERPROFILE\.dsh\w2m\agent.log" -Tail 20
111
130
 
@@ -40,6 +40,10 @@
40
40
  # Get-ScheduledTask -TaskName 'W2M Localside agent' | Get-ScheduledTaskInfo
41
41
  # powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -Uninstall
42
42
  #
43
+ # # repair a task registered by an older version of this script (one that used
44
+ # # `MultipleInstances IgnoreNew` and therefore could not recover from a supervisor death):
45
+ # powershell -ExecutionPolicy Bypass -File deploy\windows\install-service.ps1 -UpdateTask
46
+ #
43
47
  # Everything after the parameters goes to the agent, so `-AgentArgs
44
48
  # '--p2p-port','41235'` pins the punch socket (and then one inbound firewall rule
45
49
  # is enough, instead of a rule per restart).
@@ -58,6 +62,8 @@ param(
58
62
  [int]$RestartBackoffMs = 60000,
59
63
  [int]$WatchdogMinutes = 5,
60
64
  [switch]$Interactive,
65
+ [switch]$UpdateTask,
66
+ [switch]$UpdateConfig,
61
67
  [switch]$Uninstall,
62
68
  [switch]$WhatIfOnly
63
69
  )
@@ -65,6 +71,54 @@ param(
65
71
  $ErrorActionPreference = 'Stop'
66
72
  $repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) # ...\deploy\windows -> repo root
67
73
  $supervisor = Join-Path $PSScriptRoot 'w2m-agent-supervisor.mjs'
74
+
75
+ # ---------------------------------------------------------------------------------------------
76
+ # -UpdateTask: repair an EXISTING registration, and touch nothing else.
77
+ #
78
+ # This exists because the task and the supervisor have to agree, and an installation registered by an
79
+ # older version of this script does not: `MultipleInstances IgnoreNew` together with the watchdog
80
+ # trigger means a task whose supervisor died while the task still reports Running can never start
81
+ # again. Measured on this machine: one supervisor, one agent, the port bound, and no way back.
82
+ #
83
+ # It runs FIRST and RETURNS, before the agent config is written and before node is even looked for,
84
+ # so it needs no elevation: `Set-ScheduledTask` on a task this user already owns is permitted
85
+ # (measured), while re-registering the S4U principal is not. Triggers and settings only -- the action,
86
+ # the principal and `%USERPROFILE%\.dsh\w2m\agent.json` are left exactly as they are.
87
+ # ---------------------------------------------------------------------------------------------
88
+ if ($UpdateTask) {
89
+ if (-not (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue)) {
90
+ Write-Error "nothing to update: $TaskName is not registered. Run without -UpdateTask to install it."
91
+ return
92
+ }
93
+ $watchdog = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
94
+ -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
95
+ -RepetitionDuration ([TimeSpan]::FromDays(3650))
96
+ $existingTriggers = (Get-ScheduledTask -TaskName $TaskName).Triggers
97
+ $isInteractive = -not ($existingTriggers | Where-Object { $_.CimClass.CimClassName -eq 'MSFT_TaskBootTrigger' })
98
+ $updateTriggers = if ($isInteractive) {
99
+ @((New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"), $watchdog)
100
+ } else {
101
+ @(
102
+ (New-ScheduledTaskTrigger -AtStartup),
103
+ (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"),
104
+ $watchdog
105
+ )
106
+ }
107
+ $updateSettings = New-ScheduledTaskSettingsSet `
108
+ -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
109
+ -MultipleInstances Parallel `
110
+ -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
111
+ -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
112
+ -StartWhenAvailable -Hidden
113
+ Set-ScheduledTask -TaskName $TaskName -Trigger $updateTriggers -Settings $updateSettings | Out-Null
114
+ $updated = Get-ScheduledTask -TaskName $TaskName
115
+ Write-Host "updated $TaskName (triggers and settings only; the action and the config are untouched)"
116
+ Write-Host " multiple instances : $($updated.Settings.MultipleInstances) (Parallel: the supervisor's lock is what prevents a second agent)"
117
+ Write-Host " watchdog : every $WatchdogMinutes min, indefinitely"
118
+ Write-Host " state : $($updated.State)"
119
+ return
120
+ }
121
+
68
122
  $node = (Get-Command node -ErrorAction SilentlyContinue).Source
69
123
  if (-not $node) { throw 'node is not on PATH; install Node 20.19+ first' }
70
124
  if (-not (Test-Path $supervisor)) { throw "supervisor missing: $supervisor" }
@@ -124,6 +178,27 @@ if ($WhatIfOnly) {
124
178
  $config | ConvertTo-Json -Depth 4
125
179
  return
126
180
  }
181
+ # THE CONFIG IS THE PART THAT CANNOT BE REBUILT FROM DEFAULTS.
182
+ #
183
+ # Measured the hard way while testing this deployment: re-running the installer with no parameters
184
+ # overwrote a live `agent.json` with the defaults -- the project became the current directory, the
185
+ # machine name became the hostname, and `--p2p-mode auto --p2p-port 41235` disappeared, so the agent
186
+ # immediately took an ephemeral port and every dispatch would have fallen back to the relay. Nothing
187
+ # errored and nothing warned; the file was simply replaced. Refusing by default, and naming exactly
188
+ # what would be lost, is one line of protection against the failure this whole directory is about.
189
+ if ((Test-Path $configPath) -and -not $UpdateConfig -and -not $WhatIfOnly) {
190
+ $current = Get-Content $configPath -Raw | ConvertFrom-Json
191
+ if ($current.project -ne $Project -or $current.name -ne $MachineName -or [string]$current.agentArgs -ne ($agentArgs -join ' ')) {
192
+ Write-Error @"
193
+ $configPath already exists and differs from what this run would write:
194
+ now : project=$($current.project) name=$($current.name) agentArgs="$($current.agentArgs)"
195
+ next: project=$Project name=$MachineName agentArgs="$($agentArgs -join ' ')"
196
+ Re-run with -UpdateConfig to replace it, or with -WhatIfOnly to see the whole file first.
197
+ A silent replacement is how a machine loses its pinned punch port and its identity.
198
+ "@
199
+ return
200
+ }
201
+ }
127
202
  New-Item -ItemType Directory -Force -Path (Split-Path -Parent $configPath) | Out-Null
128
203
  # Write it WITHOUT a BOM. `Set-Content -Encoding utf8` on PowerShell 5.1 prepends one, `JSON.parse`
129
204
  # refuses it, and the supervisor then exits 78 before it has opened its log -- a failure with no
@@ -135,28 +210,30 @@ Write-Host "wrote $configPath"
135
210
 
136
211
  $action = New-ScheduledTaskAction -Execute $node -Argument ($arguments -join ' ') -WorkingDirectory $PSScriptRoot
137
212
  # ---------------------------------------------------------------------------------------------
138
- # SELF-HEALING WITHOUT ELEVATION
213
+ # THE WATCHDOG TRIGGER, AND WHY THE TASK MAY NOW RUN MORE THAN ONE INSTANCE
139
214
  #
140
215
  # Measured twice on this machine: the whole process tree (supervisor + agent) is killed with
141
216
  # 0xC000013A (STATUS_CONTROL_C_EXIT -- its console went away), the log ends mid-sentence, and Task
142
217
  # Scheduler does NOT bring it back even with RestartCount 999: the task simply ends. The fleet then
143
- # silently loses the machine, which is exactly the failure a supervisor was supposed to prevent --
144
- # a supervisor that shares the console dies with it.
145
- #
146
- # A repeating trigger fixes that without any administrator right: combined with
147
- # `MultipleInstances IgnoreNew` the task means "be running, or start", so a death from any cause is
148
- # repaired at the next tick. The window is the repetition interval, and that is the honest trade:
149
- # up to N minutes offline after a console-close death, versus needing elevation for a session-0
150
- # service (the real fix, and the default mode above).
218
+ # silently loses the machine, which is exactly the failure a supervisor was supposed to prevent.
219
+ #
220
+ # A repeating trigger fixes that without any administrator right: the task means "be running, or
221
+ # start", so a death from any cause is repaired at the next tick. Until recently that trigger had to
222
+ # be paired with `MultipleInstances IgnoreNew` to keep it from producing a second agent, and that
223
+ # pairing is what cost this machine a day: measured, a supervisor killed while the task still
224
+ # reported Running left the task in `Running` forever (the rejected start is 0x800710E0, "the
225
+ # operator or administrator has refused the request"), so the watchdog could never fire again while
226
+ # the detached agent kept the punch port -- one supervisor, one agent, port bound, and no way back.
227
+ #
228
+ # `MultipleInstances Parallel` is now the correct setting, because the supervisor's own lock is what
229
+ # makes a second start harmless: a generation that finds the lock held logs a no-op line and exits
230
+ # without spawning, so a tick during a healthy run costs one short-lived process and nothing else.
231
+ # The trigger therefore does what it was written to do -- repair a dead supervisor -- and cannot do
232
+ # the thing it was feared for.
151
233
  # ---------------------------------------------------------------------------------------------
152
- $repetition = if ($Interactive) {
153
- # `[TimeSpan]::MaxValue` is NOT a valid duration here -- Task Scheduler rejects it and the whole
154
- # registration fails (measured: the script had already stopped the old task, so the machine was
155
- # left with no agent at all). Ten years is the idiomatic stand-in for "repeat indefinitely".
156
- New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
157
- -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
158
- -RepetitionDuration ([TimeSpan]::FromDays(3650))
159
- } else { $null }
234
+ $repetition = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(1) `
235
+ -RepetitionInterval (New-TimeSpan -Minutes $WatchdogMinutes) `
236
+ -RepetitionDuration ([TimeSpan]::FromDays(3650))
160
237
  # AtStartup only makes sense for the session-0 (S4U) shape, which also runs whether or not anyone is
161
238
  # signed in. Registering an AtStartup trigger on an interactive task is refused without elevation --
162
239
  # measured, and the reason this list is conditional rather than always two entries.
@@ -165,15 +242,17 @@ $triggers = if ($Interactive) {
165
242
  } else {
166
243
  @(
167
244
  (New-ScheduledTaskTrigger -AtStartup),
168
- (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME")
245
+ (New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME"),
246
+ $repetition
169
247
  )
170
248
  }
171
249
  $settings = New-ScheduledTaskSettingsSet `
172
250
  -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries `
173
- -MultipleInstances IgnoreNew `
251
+ -MultipleInstances Parallel `
174
252
  -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
175
253
  -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
176
254
  -StartWhenAvailable -Hidden
255
+
177
256
  $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
178
257
  -LogonType $(if ($Interactive) { 'Interactive' } else { 'S4U' }) -RunLevel Limited
179
258