@ran-sh/dsh-crew 2.2.7 → 2.2.9

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.2.7",
3
+ "version": "2.2.9",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
@@ -13,15 +13,27 @@ The matrix is emitted by `hubStatus()` and is therefore visible inside the exist
13
13
  - `BLOCKED` — the check could not run because required infrastructure or authorization was unavailable.
14
14
  - `SKIP` — the row is intentionally not applicable for the active policy/path.
15
15
  - `NOT_APPLICABLE` — the row asks about a route this machine does not use, and the
16
- matrix says so instead of omitting it. The two built-in DeepSeek rows are the live
17
- case: they read `NOT_APPLICABLE` / `WORKER_PROVIDER_FOLLOWS_DSH` whenever a known,
18
- other provider is selected, and keep `NOT_RUN` when the selection is unknown.
16
+ matrix says so instead of omitting it. The two built-in DeepSeek rows are the only
17
+ rows a policy may mark this way: they read `NOT_APPLICABLE` /
18
+ `WORKER_PROVIDER_FOLLOWS_DSH` whenever a known, other provider is selected, and keep
19
+ `NOT_RUN` when the selection is unknown.
19
20
  - `NOT_RUN` — no trusted evidence has been supplied for the row.
20
21
 
21
- `BLOCKED` and `SKIP` are not failures. `NOT_RUN` is not success, and `NOT_APPLICABLE`
22
- is an answer: it is reported as itself on any component that consumes the row, and it
23
- cannot lower the readiness aggregate — only a `FAIL`, an unproven row or an
24
- `UNAVAILABLE` component does.
22
+ `BLOCKED` and `SKIP` are not failures. `NOT_RUN` is not success.
23
+
24
+ Applicability is decided when the row is built and it is final for that row:
25
+
26
+ - Supplied evidence can neither create nor clear it. `NOT_APPLICABLE` is not part of
27
+ the evidence vocabulary (`PASS`, `FAIL`, `BLOCKED`, `SKIP`, `NOT_RUN`), so no
28
+ evidence source can make a required row disappear from readiness, and evidence
29
+ reported against a not-applicable row is kept as `reported_evidence` metadata
30
+ instead of flipping the status back.
31
+ - A required row that arrives as `NOT_APPLICABLE` anyway is a contradiction, not a
32
+ pass: consumers that project such a row into readiness report
33
+ `UNAVAILABLE` / `CHECK_NOT_APPLICABLE_ON_REQUIRED_ROW`, which cannot leave the
34
+ aggregate READY.
35
+ - The matrix declares `schema_version: 2`: a row may legally be `NOT_APPLICABLE` and
36
+ the summary carries a matching key, which is what a strict consumer needs to know.
25
37
 
26
38
  ## Evidence classes
27
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.2.7",
3
+ "version": "2.2.9",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -389,7 +389,7 @@ export async function setupStatus({ log = console.log, root = ROOT, home = homed
389
389
  // of the installation, which it is not.
390
390
  const frontendConfig = (installer.readGlobalConfig ?? realInstaller.readGlobalConfig)({ configFile: join(home, '.config', 'dsh-crew', 'config.json') });
391
391
  const frontend = frontendConfig?.frontend_autostart === true
392
- ? 'auto-started at login (dsh-crew open for a session URL)'
392
+ ? 'configured to auto-start at login (dsh-crew open for a session URL)'
393
393
  : 'available on demand (dsh-crew open)';
394
394
  log(`Frontend (3080): ${frontend}`);
395
395
  log(`Codex Desktop integration: ${codex}`);
@@ -29,13 +29,19 @@ function workspaceComponent(workspace) {
29
29
  : { ...component('UNAVAILABLE', workspace?.code ?? 'WORKSPACE_NOT_CHECKED'), state: 'UNAVAILABLE' };
30
30
  }
31
31
 
32
- function readinessFromRow(entry, { pass = 'READY', notRun = 'DEGRADED' } = {}) {
32
+ function readinessFromRow(entry, { pass = 'READY', notRun = 'DEGRADED', allowNotApplicable = false } = {}) {
33
33
  if (!entry) return component('UNAVAILABLE', 'NO_EVIDENCE');
34
34
  if (entry.status === 'PASS') return component(pass, entry.reason_code ?? 'CHECK_PASSED');
35
- // A row that does not apply to this machine is reported as such, not as a check
36
- // that has not run: NOT_APPLICABLE says the question was answered, and it cannot
37
- // pull the aggregate down (see the readiness roll-up below).
38
- if (entry.status === 'NOT_APPLICABLE') return component('NOT_APPLICABLE', entry.reason_code ?? 'CHECK_NOT_APPLICABLE');
35
+ // Every row this function is called with asks a question that applies to any Crew
36
+ // machine (a reachable hub, a consistent provider lifecycle, a runnable route), so
37
+ // "not applicable" is a contradiction here, not a pass: it is reported as missing
38
+ // evidence and it cannot leave the aggregate READY. Only a caller that names a row
39
+ // which may legitimately not apply opts in.
40
+ if (entry.status === 'NOT_APPLICABLE') {
41
+ return allowNotApplicable
42
+ ? component('NOT_APPLICABLE', entry.reason_code ?? 'CHECK_NOT_APPLICABLE')
43
+ : component('UNAVAILABLE', 'CHECK_NOT_APPLICABLE_ON_REQUIRED_ROW');
44
+ }
39
45
  if (entry.status === 'NOT_RUN' || entry.status === 'SKIP') return component(notRun, entry.reason_code ?? 'CHECK_NOT_RUN');
40
46
  return component('UNAVAILABLE', entry.reason_code ?? 'CHECK_FAILED');
41
47
  }
package/src/hub/index.mjs CHANGED
@@ -642,6 +642,10 @@ export class WorkerRegistry { constructor(ctx) {
642
642
  delivery_complete: !!job.delivery_complete,
643
643
  allow_no_changes: job.allow_no_changes === true,
644
644
  task_status: job.outcome?.task_status ?? null,
645
+ // Whether the worker's execution itself completed, kept beside the task-level
646
+ // verdict: a `partial` task still had a provider response, and that is what
647
+ // model callability asks about.
648
+ execution_status: job.outcome?.execution_status ?? null,
645
649
  workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
646
650
  review_verdict: job.review?.verdict ?? null,
647
651
  workspace_diff_available: !!job.workspaceDiff && ['git', 'filesystem-empty'].includes(job.workspaceDiff.kind),
@@ -1247,12 +1247,35 @@ export function installHudSegment({ home = homedir() } = {}) {
1247
1247
  return { ok: true, actions: [...(bak ? [`backup: ${bak}`] : []), 'statusLine: claude-hud now runs worker-segment.sh via --extra-cmd'] };
1248
1248
  }
1249
1249
 
1250
- // Only the status line `--statusline` installs is Crew's to remove: it points at
1251
- // Crew's own script. Any other status line in the file belongs to whoever
1252
- // configured it, even when it happens to name this checkout.
1253
- function crewOwnedStatusLine(value) {
1254
- const command = typeof value?.command === 'string' ? value.command : '';
1255
- return command.includes('statusline.sh') && command.includes('dsh-crew');
1250
+ // The paths Crew has recorded as its own in this Claude host: the marketplace records
1251
+ // it wrote (under the current and pre-rename names, in settings and in the CLI's own
1252
+ // cache), the constant an older installer used, and the checkout this module runs
1253
+ // from. Only these are grounds for removing anything: a directory that merely happens
1254
+ // to be named `dsh-crew` belongs to whoever made it, and a path that is not recorded
1255
+ // here cannot be identified as Crew's, so it is left alone.
1256
+ function crewOwnedRoots({ home, settings, markets, installed }) {
1257
+ const roots = [];
1258
+ const push = (value) => { if (typeof value === 'string' && value.trim() && !roots.includes(value.trim())) roots.push(value.trim()); };
1259
+ for (const name of CREW_CLAUDE_MARKETPLACES) {
1260
+ push(settings?.extraKnownMarketplaces?.[name]?.source?.path);
1261
+ push(markets?.[name]?.source?.path);
1262
+ push(markets?.[name]?.installLocation);
1263
+ }
1264
+ for (const key of CREW_CLAUDE_PLUGIN_KEYS) {
1265
+ const entries = Array.isArray(installed?.plugins?.[key]) ? installed.plugins[key] : [installed?.plugins?.[key]];
1266
+ for (const entry of entries) push(entry?.installPath);
1267
+ }
1268
+ push(join(home, '.config', 'dsh-crew', 'marketplace'));
1269
+ push(ROOT);
1270
+ return roots;
1271
+ }
1272
+
1273
+ function crewOwnedStatusLine(value, roots) {
1274
+ const command = typeof value?.command === 'string' ? value.command.trim() : '';
1275
+ const match = /^bash\s+(.+)$/i.exec(command);
1276
+ if (!match) return false;
1277
+ const path = normalizedPath(match[1].trim());
1278
+ return path !== null && roots.some((root) => normalizedPath(join(root, 'statusline', 'statusline.sh')) === path);
1256
1279
  }
1257
1280
 
1258
1281
  // The claude CLI records, per marketplace and plugin, what it materialized, in
@@ -1290,12 +1313,19 @@ function removeCrewClaudeCacheRecords({ home, actions }) {
1290
1313
  export function uninstallClaudeCode({ home = homedir() } = {}) {
1291
1314
  const actions = [];
1292
1315
  const settingsFile = join(home, '.claude', 'settings.json');
1316
+ const cacheDir = join(home, '.claude', 'plugins');
1293
1317
  const settings = readJson(settingsFile, null);
1318
+ const markets = readJson(join(cacheDir, 'known_marketplaces.json'), null);
1319
+ const installed = readJson(join(cacheDir, 'installed_plugins.json'), null);
1320
+ const roots = crewOwnedRoots({ home, settings, markets, installed });
1294
1321
  if (settings) {
1295
1322
  backup(settingsFile);
1296
- const mpDir = join(home, '.config', 'dsh-crew', 'marketplace');
1297
1323
  if (Array.isArray(settings.extraKnownMarketplaces)) {
1298
- settings.extraKnownMarketplaces = settings.extraKnownMarketplaces.filter((m) => m?.path !== mpDir);
1324
+ // Legacy array shape: an entry is removed only when it IS one of the paths Crew
1325
+ // recorded as its own. What the path is spelled with is not evidence — a
1326
+ // marketplace under someone else's `dsh-crew` directory stays.
1327
+ settings.extraKnownMarketplaces = settings.extraKnownMarketplaces
1328
+ .filter((entry) => !roots.some((root) => normalizedPath(entry?.path) === normalizedPath(root)));
1299
1329
  } else if (settings.extraKnownMarketplaces) {
1300
1330
  for (const name of CREW_CLAUDE_MARKETPLACES) delete settings.extraKnownMarketplaces[name];
1301
1331
  }
@@ -1308,7 +1338,7 @@ export function uninstallClaudeCode({ home = homedir() } = {}) {
1308
1338
  settings.permissions.allow = settings.permissions.allow.filter((rule) => typeof rule !== 'string'
1309
1339
  || !CREW_CLAUDE_PERMISSION_PREFIXES.some((prefix) => rule.startsWith(prefix)));
1310
1340
  }
1311
- if (crewOwnedStatusLine(settings.statusLine)) {
1341
+ if (crewOwnedStatusLine(settings.statusLine, roots)) {
1312
1342
  delete settings.statusLine;
1313
1343
  actions.push('statusLine: removed (Crew-installed)');
1314
1344
  }
@@ -3333,7 +3333,7 @@ export function npxStatus({
3333
3333
  // whether 3080 happens to be up right now does not change either answer.
3334
3334
  const frontendConfig = (installer.readGlobalConfig ?? realInstaller.readGlobalConfig)({ configFile: join(home, '.config', 'dsh-crew', 'config.json') });
3335
3335
  const frontend = frontendConfig?.frontend_autostart === true
3336
- ? 'auto-started at login (dsh-crew open for a session URL)'
3336
+ ? 'configured to auto-start at login (dsh-crew open for a session URL)'
3337
3337
  : 'available on demand (dsh-crew open)';
3338
3338
  log(`Frontend (3080): ${frontend}`);
3339
3339
  // The desktop app carries its own bridge entry, which pins a revision: after a
package/src/log-prune.mjs CHANGED
@@ -2,9 +2,12 @@
2
2
  // new dsh-crew-dsh-crew-3210-<stamp>.out/.err.log pair, every desktop launch a
3
3
  // dsh-crew-web-*, every official launch a dsh-official-web-*, and the launcher
4
4
  // appends to dsh-crew-launcher.log forever. They are the only post-mortem evidence
5
- // for a crash-before-readiness, so the rule keeps the most recent runs instead of
6
- // deleting everything: per family, the newest `keep` runs survive, plus anything
7
- // written within the recency guard (a running process owns its own log files).
5
+ // for a crash-before-readiness, so the rule bounds them instead of deleting them all.
6
+ // A run survives when it is BOTH within its family's newest `keep` AND younger than
7
+ // the age bound; exceeding either one removes it, unless it was written within the
8
+ // recency guard (a running process owns its own log files). The newest `keep` runs
9
+ // therefore do not survive unconditionally — only a run that is also inside the age
10
+ // bound does, which is what keeps a burst of starts from filling the directory.
8
11
 
9
12
  import { readdirSync, rmSync, statSync } from 'node:fs';
10
13
  import { join } from 'node:path';
@@ -14,10 +17,15 @@ export const DEFAULT_KEEP_RUNS = 10;
14
17
  export const DEFAULT_MAX_AGE_DAYS = 14;
15
18
  const RECENT_GUARD_MS = 5 * 60 * 1000;
16
19
 
20
+ // Per family. The hub-start pairs are what a crash-before-readiness leaves behind,
21
+ // and ten of them are the post-mortem window the policy names; the frontend families
22
+ // are launched far less often and keep the retention they already had, so one
23
+ // family's bound is not silently imposed on the others. An explicit `keepRuns`
24
+ // still applies to every family: that is an operator stating how many runs to keep.
17
25
  const LOG_FAMILIES = [
18
- { prefix: 'dsh-crew-dsh-crew-3210-', suffix: '.out.log' },
19
- { prefix: 'dsh-crew-web-', suffix: '.out.log' },
20
- { prefix: 'dsh-official-web-', suffix: '.out.log' },
26
+ { prefix: 'dsh-crew-dsh-crew-3210-', suffix: '.out.log', keep: 10 },
27
+ { prefix: 'dsh-crew-web-', suffix: '.out.log', keep: 20 },
28
+ { prefix: 'dsh-official-web-', suffix: '.out.log', keep: 20 },
21
29
  ];
22
30
 
23
31
  // `dsh-crew-<profile>-<port>-<stamp>.out.log` -> the stamp identifies one run, and
@@ -28,8 +36,11 @@ function runStamp(fileName, family) {
28
36
  return stamp || null;
29
37
  }
30
38
 
31
- export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = DEFAULT_KEEP_RUNS, maxAgeDays = DEFAULT_MAX_AGE_DAYS, now = Date.now() } = {}) {
32
- const keep = Number.isInteger(keepRuns) && keepRuns > 0 ? keepRuns : DEFAULT_KEEP_RUNS;
39
+ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = null, maxAgeDays = DEFAULT_MAX_AGE_DAYS, now = Date.now() } = {}) {
40
+ // `keepRuns` is an explicit operator override for every family; without one each
41
+ // family uses its own bound, so the default is "nothing was asked for" rather than
42
+ // a number that would flatten them all to the hub's ten.
43
+ const keepOverride = Number.isInteger(keepRuns) && keepRuns > 0 ? keepRuns : null;
33
44
  const maxAgeMs = Number.isFinite(maxAgeDays) && maxAgeDays > 0 ? maxAgeDays * 24 * 60 * 60 * 1000 : DEFAULT_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
34
45
  let names;
35
46
  try { names = readdirSync(tempDir); } catch { return { ok: true, removed: [], kept: [], skipped: 'unreadable' }; }
@@ -50,10 +61,16 @@ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = DEFAULT_KEEP_
50
61
  runs.set(stamp, run);
51
62
  }
52
63
  const ordered = [...runs.values()].sort((left, right) => right.newest - left.newest);
64
+ const keep = keepOverride ?? family.keep;
53
65
  ordered.forEach((run, index) => {
54
66
  const tooOld = now - run.newest > maxAgeMs;
55
67
  const tooMany = index >= keep;
56
68
  const tooRecentToTouch = now - run.newest < RECENT_GUARD_MS;
69
+ // Delete when EITHER bound is exceeded. The newest `keep` runs are what an
70
+ // operator reads, and past that a run beyond the age bound is gone regardless
71
+ // of its rank: keeping an eleventh run merely because it is only hours old
72
+ // would let a burst of starts grow without bound, which is the growth this
73
+ // bound exists to stop. A burst of eleven young runs still keeps ten.
57
74
  if ((tooOld || tooMany) && !tooRecentToTouch) {
58
75
  for (const name of run.files) removed.push(name);
59
76
  const errName = run.files[0].replace(/\.out\.log$/, '.err.log');
@@ -6,6 +6,17 @@
6
6
  // verification (real worker, reviewer, cancellation, timeout, etc.).
7
7
 
8
8
  const READINESS_STATUSES = Object.freeze(['PASS', 'FAIL', 'BLOCKED', 'SKIP', 'NOT_APPLICABLE', 'NOT_RUN']);
9
+ // Statuses a trusted evidence source may report. `NOT_APPLICABLE` is deliberately
10
+ // absent: applicability is a fact about this machine's policy, decided when the row
11
+ // is built, and letting evidence declare a required row "not applicable" would be a
12
+ // way to make a check disappear while the matrix still reads healthy. Evidence can
13
+ // neither create nor clear it.
14
+ const EVIDENCE_STATUSES = Object.freeze(['PASS', 'FAIL', 'BLOCKED', 'SKIP', 'NOT_RUN']);
15
+ // The rows this release may decide are not applicable, and only when the reason is
16
+ // policy rather than observation: the two built-in DeepSeek routes exist to name a
17
+ // provider the operator may not be using. Every other row asks a question that is
18
+ // always applicable to a Crew machine.
19
+ const POLICY_NOT_APPLICABLE_ROWS = Object.freeze(['deepseek_flash', 'deepseek_pro']);
9
20
 
10
21
  export const READINESS_REASON_CODES = Object.freeze({
11
22
  LIVE_CHECK_PASSED: 'LIVE_CHECK_PASSED',
@@ -59,7 +70,24 @@ function baseRow(id, category, status, reasonCode, evidenceSource = 'none', extr
59
70
 
60
71
  function normalizeEvidence(row, evidence) {
61
72
  if (!evidence || typeof evidence !== 'object') return row;
62
- if (!READINESS_STATUSES.includes(evidence.status)) return row;
73
+ if (!EVIDENCE_STATUSES.includes(evidence.status)) return row;
74
+ // A row the policy marked not applicable keeps that status: reported evidence says
75
+ // something happened, not that the current worker route changed, and letting it
76
+ // flip the row is how "this route is unused" would turn into "this route passed".
77
+ // What was reported is kept as metadata rather than thrown away.
78
+ if (row.status === 'NOT_APPLICABLE') {
79
+ // The same sanitized fields an applied record keeps, so "the observation is not
80
+ // lost" is a statement the code supports rather than a claim about three of five.
81
+ const source = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
82
+ const reported = {
83
+ status: evidence.status,
84
+ ...(source(evidence.reason_code) ? { reason_code: source(evidence.reason_code) } : {}),
85
+ ...(source(evidence.evidence_source) ? { evidence_source: source(evidence.evidence_source) } : {}),
86
+ ...(source(evidence.evidence_ref) ? { evidence_ref: source(evidence.evidence_ref) } : {}),
87
+ ...(source(evidence.detail_code) && /^[A-Z][A-Z0-9_]{0,127}$/u.test(evidence.detail_code) ? { detail_code: evidence.detail_code } : {}),
88
+ };
89
+ return { ...row, reported_evidence: reported };
90
+ }
63
91
  const reason = typeof evidence.reason_code === 'string' && evidence.reason_code.trim()
64
92
  ? evidence.reason_code.trim()
65
93
  : READINESS_REASON_CODES.EVIDENCE_REPORTED;
@@ -171,12 +199,13 @@ export function buildReadinessMatrix({
171
199
  // answered), and neither status can move the aggregate — only a FAIL, an
172
200
  // UNPROVEN row or an UNAVAILABLE component does. An unknown selection stays
173
201
  // NOT_RUN: this matrix is conservative, and "we could not tell" must not read as
174
- // "not applicable". Reported evidence still wins, because it is applied below.
202
+ // "not applicable". This policy decision is also final for the row: evidence
203
+ // applied below records what it saw without changing the status back.
175
204
  const selectionKnown = typeof workerSelection?.provider === 'string' && workerSelection.provider.length > 0;
176
205
  const deepseekRouteSelected = workerProviderMode === 'deepseek-official'
177
206
  || workerSelection?.provider === 'deepseek-official';
178
- if (workerProviderMode !== null && selectionKnown && !deepseekRouteSelected) {
179
- for (const id of ['deepseek_flash', 'deepseek_pro']) {
207
+ if (workerProviderMode === 'follow-dsh' && selectionKnown && !deepseekRouteSelected) {
208
+ for (const id of POLICY_NOT_APPLICABLE_ROWS) {
180
209
  rows[id] = baseRow(id, 'real-execution', 'NOT_APPLICABLE', READINESS_REASON_CODES.WORKER_PROVIDER_FOLLOWS_DSH, 'runtime-policy');
181
210
  }
182
211
  }
@@ -192,7 +221,10 @@ export function buildReadinessMatrix({
192
221
  ]));
193
222
 
194
223
  return {
195
- schema_version: 1,
224
+ // A row can now legally be NOT_APPLICABLE and the summary carries a matching key,
225
+ // so the vocabulary a strict consumer must accept changed: that is what the
226
+ // version means here, not a change in the rules that produce PASS.
227
+ schema_version: 2,
196
228
  platform,
197
229
  conservative: true,
198
230
  summary: counts,
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.2.7';
39
+ export const RUNTIME_VERSION = '2.2.9';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -108,9 +108,17 @@ function projectModelRole({ role, selected, health, jobs, runtime, now, executio
108
108
  if (healthCurrent && matchingHealth.state === 'callable') {
109
109
  return { state: 'CALLABLE', reason_code: matchingHealth.reason_code ?? 'PROVIDER_CALLABLE', selected, observed_at: matchingHealth.observed_at ?? null, expires_at: expiresAt, source: 'provider_health', last_success: null };
110
110
  }
111
+ // Callability asks for positive proof that the provider answered, so the ledger
112
+ // fact that proves it is the one that says the execution ended normally: a job the
113
+ // worker recorded as `done` whose `execution_status` is `completed`. The task-level
114
+ // verdict is deliberately NOT part of this — a `partial` task still had a provider
115
+ // response — and a job whose execution failed or was never recorded is not evidence
116
+ // of a response, however its task was later classified. A recovery probe or a real
117
+ // run is what a route without such a job needs.
118
+ const responded = (job) => job?.execution_status === 'completed';
111
119
  const completed = (Array.isArray(jobs) ? jobs : [])
112
120
  .filter((job) => job?.role === role && job?.provider === selected.provider && job?.model === selected.model
113
- && job?.status === 'done' && job?.task_status === 'success'
121
+ && job?.status === 'done' && responded(job)
114
122
  && sameCompleteRuntimeIdentity(job.execution_context, runtime))
115
123
  .map((job) => ({ job, endedAt: Date.parse(job.endedAt ?? '') }))
116
124
  .filter(({ endedAt }) => Number.isFinite(endedAt))
@@ -32,6 +32,7 @@ if not "%~1"=="" goto :invalid_argument
32
32
  set "LAUNCH_HELPER=%LAUNCH_DIR%start-dsh-crew.ps1"
33
33
  set "LAUNCH_LOG=%TEMP%\dsh-crew-launcher.log"
34
34
  if not exist "%LAUNCH_HELPER%" (
35
+ call :rotate_launcher_log
35
36
  >>"%LAUNCH_LOG%" echo [%date% %time%] ERROR Managed launcher helper is missing: %LAUNCH_HELPER%
36
37
  echo ERROR: DSH Crew launcher helper is missing.
37
38
  echo Repair it with: dsh-crew update
@@ -51,6 +52,21 @@ echo ERROR: Unsupported launcher arguments: %LAUNCH_REQUEST%
51
52
  echo Use --open, --background, or --watch.
52
53
  exit /b 64
53
54
 
55
+ :rotate_launcher_log
56
+ rem The PowerShell launcher bounds this log at 5 MiB before its first line, but the
57
+ rem emergency path that calls this is what runs when that helper is MISSING — so a
58
+ rem bounded log cannot depend on the helper whose absence is one of the failures the
59
+ rem log exists to diagnose. Same cap, same two generations, best-effort: a locked or
60
+ rem unreadable log is left alone rather than failing the launch.
61
+ set "LAUNCH_LOG_SIZE="
62
+ for %%A in ("%LAUNCH_LOG%") do set "LAUNCH_LOG_SIZE=%%~zA"
63
+ if not defined LAUNCH_LOG_SIZE goto :eof
64
+ if %LAUNCH_LOG_SIZE% LSS 5242880 goto :eof
65
+ if exist "%LAUNCH_LOG%.2" del /f /q "%LAUNCH_LOG%.2" >nul 2>&1
66
+ if exist "%LAUNCH_LOG%.1" move /y "%LAUNCH_LOG%.1" "%LAUNCH_LOG%.2" >nul 2>&1
67
+ move /y "%LAUNCH_LOG%" "%LAUNCH_LOG%.1" >nul 2>&1
68
+ goto :eof
69
+
54
70
  :help
55
71
  echo Usage: %~nx0 [--open ^| --background ^| --watch]
56
72
  echo --open Open official Harness on 3080 and return once Crew is supervised;