session-orchestrator 3.16.0 → 3.19.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/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -46,6 +46,7 @@ import { join } from 'node:path';
|
|
|
46
46
|
import { randomBytes } from 'node:crypto';
|
|
47
47
|
|
|
48
48
|
import { readPeerCards } from './lib/peer-cards/reader.mjs';
|
|
49
|
+
import { filterRealSessions } from './lib/session-schema/filters.mjs';
|
|
49
50
|
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
51
52
|
// Constants
|
|
@@ -392,7 +393,11 @@ async function readTopLearnings(repoRoot, topN) {
|
|
|
392
393
|
}
|
|
393
394
|
|
|
394
395
|
/**
|
|
395
|
-
* Read and rank sessions:
|
|
396
|
+
* Read and rank sessions: filter out phantom `status: 'abandoned'` stubs
|
|
397
|
+
* (#834, session-close-backfill), sort by `completed_at` DESC, take last K.
|
|
398
|
+
*
|
|
399
|
+
* Without the filter, phantoms (0 waves, seconds of runtime) can displace
|
|
400
|
+
* REAL session context out of the K-window the derivation prompt sees.
|
|
396
401
|
*
|
|
397
402
|
* @param {string} repoRoot
|
|
398
403
|
* @param {number} lastK
|
|
@@ -400,7 +405,7 @@ async function readTopLearnings(repoRoot, topN) {
|
|
|
400
405
|
*/
|
|
401
406
|
async function readLastSessions(repoRoot, lastK) {
|
|
402
407
|
const path = join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
|
|
403
|
-
const entries = await readJsonlBestEffort(path);
|
|
408
|
+
const entries = filterRealSessions(await readJsonlBestEffort(path));
|
|
404
409
|
entries.sort((a, b) => {
|
|
405
410
|
const ta = typeof a?.completed_at === 'string' ? a.completed_at : '';
|
|
406
411
|
const tb = typeof b?.completed_at === 'string' ? b.completed_at : '';
|
package/scripts/eval-session.mjs
CHANGED
|
@@ -10,6 +10,36 @@
|
|
|
10
10
|
* - Data → stdout, diagnostics → stderr.
|
|
11
11
|
* - Exit codes: 0 success/match · 1 user-error/drift · 2 system error.
|
|
12
12
|
*
|
|
13
|
+
* ## Append failures are NOT exit 0 (GitLab #969)
|
|
14
|
+
*
|
|
15
|
+
* `appendEvalRecord` is a never-throw sink: it returns
|
|
16
|
+
* `{ ok:false, reason:'validation'|'fs-error' }` instead of raising. This CLI
|
|
17
|
+
* used to log that WARN and still `process.exit(0)` — the caller saw success
|
|
18
|
+
* while the journal had no record, one surface reporting a fact the other did
|
|
19
|
+
* not carry. A failed append now exits non-zero, split by reason:
|
|
20
|
+
*
|
|
21
|
+
* - `fs-error` → EXIT_SYSTEM (2). The filesystem refused the append
|
|
22
|
+
* (permissions, ENOTDIR, full disk) — "system error" per cli-design.md.
|
|
23
|
+
* - `validation` → EXIT_USER (1). Reachable from the invocation: `--handle ""`
|
|
24
|
+
* survives the `?? null` default as an empty string and trips the
|
|
25
|
+
* "handle must be a non-empty string or null" rule in eval/schema.mjs.
|
|
26
|
+
* Malformed metrics input reaches the same path, and cli-design.md maps
|
|
27
|
+
* "bad args, invalid file" to 1. This matches the rest of this file, where
|
|
28
|
+
* every input-derived failure is EXIT_USER and only engine crashes are
|
|
29
|
+
* EXIT_SYSTEM. (An engine-internal invariant breach also lands here — a
|
|
30
|
+
* mild under-classification, accepted so the reachable case stays honest.)
|
|
31
|
+
*
|
|
32
|
+
* Both branches emit the `--json` / human payload to stdout BEFORE exiting, and
|
|
33
|
+
* do so via `writeStdoutLineSync` rather than a queued write: `process.stdout`
|
|
34
|
+
* is asynchronous on a pipe on macOS, so `process.exit()` discards whatever is
|
|
35
|
+
* still in libuv's queue past the 65 536-byte kernel buffer. Losing the record
|
|
36
|
+
* alongside the error is the exact fail-open shape #906 fixed in the hook layer.
|
|
37
|
+
* Real records measure ≤ 5 210 bytes today, but `evidence` strings are
|
|
38
|
+
* engine-generated free text, so the bound is empirical, not structural.
|
|
39
|
+
*
|
|
40
|
+
* Session-end Phase 3.7d remains advisory and MUST NOT gate on this exit code —
|
|
41
|
+
* it catches a non-zero exit and logs a WARN (skills/session-end/SKILL.md).
|
|
42
|
+
*
|
|
13
43
|
* Usage:
|
|
14
44
|
* eval-session.mjs [--session <id>] [--json] [--no-write]
|
|
15
45
|
* [--metrics-dir <path>] [--rubric <path>]
|
|
@@ -29,6 +59,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
29
59
|
|
|
30
60
|
import { evaluateSession, diffDimensions, DEFAULT_RUBRIC_PATH, RUBRIC_VERSION } from './lib/eval/engine.mjs';
|
|
31
61
|
import { appendEvalRecord, readEvalRecords } from './lib/eval/sink.mjs';
|
|
62
|
+
import { writeStdoutLineSync } from './lib/io.mjs';
|
|
32
63
|
import { VALID_MODEL_SOURCES } from './lib/eval/schema.mjs';
|
|
33
64
|
import { SessionResolutionError } from './lib/eval/session-resolve.mjs';
|
|
34
65
|
|
|
@@ -69,8 +100,9 @@ OPTIONS
|
|
|
69
100
|
|
|
70
101
|
EXIT CODES
|
|
71
102
|
0 success / verify match
|
|
72
|
-
1 user error (session not found, unknown run-id
|
|
73
|
-
|
|
103
|
+
1 user error (session not found, unknown run-id, record failed validation)
|
|
104
|
+
/ verify drift
|
|
105
|
+
2 system error (could not append the record to the eval journal)
|
|
74
106
|
`;
|
|
75
107
|
|
|
76
108
|
function fail(exitCode, message) {
|
|
@@ -144,18 +176,27 @@ function runEvaluate(values) {
|
|
|
144
176
|
const { record, summary } = result;
|
|
145
177
|
|
|
146
178
|
let writeResult = null;
|
|
179
|
+
let appendFailureReason = null;
|
|
147
180
|
if (!values['no-write']) {
|
|
148
181
|
writeResult = appendEvalRecord(record, { path: path.join(metricsDir, 'eval.jsonl') });
|
|
149
182
|
if (!writeResult.ok) {
|
|
150
|
-
// never-throw sink already emitted
|
|
183
|
+
// The never-throw sink already emitted its own stderr WARN; add the
|
|
184
|
+
// CLI-level line, then carry the reason to the exit code below.
|
|
151
185
|
process.stderr.write(`[eval-session] append failed (${writeResult.reason}): ${writeResult.error}\n`);
|
|
186
|
+
appendFailureReason = writeResult.reason;
|
|
152
187
|
}
|
|
153
188
|
}
|
|
154
189
|
|
|
190
|
+
// stdout FIRST, synchronously — the caller must not lose the payload along
|
|
191
|
+
// with the error (see the append-failure note in the module docblock).
|
|
155
192
|
if (values.json) {
|
|
156
|
-
|
|
193
|
+
writeStdoutLineSync(JSON.stringify(record));
|
|
157
194
|
} else {
|
|
158
|
-
|
|
195
|
+
writeStdoutLineSync(renderHuman(record, summary, writeResult));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (appendFailureReason !== null) {
|
|
199
|
+
process.exit(appendFailureReason === 'validation' ? EXIT_USER : EXIT_SYSTEM);
|
|
159
200
|
}
|
|
160
201
|
process.exit(EXIT_OK);
|
|
161
202
|
}
|
|
@@ -195,15 +236,15 @@ function runVerify(runId, values) {
|
|
|
195
236
|
|
|
196
237
|
if (diffs.length === 0) {
|
|
197
238
|
if (values.json) {
|
|
198
|
-
|
|
239
|
+
writeStdoutLineSync(JSON.stringify({ run_id: runId, match: true, dimensions: fresh.dimensions.length }));
|
|
199
240
|
} else {
|
|
200
|
-
|
|
241
|
+
writeStdoutLineSync(`MATCH: ${runId} re-evaluates identically across ${fresh.dimensions.length} dimension(s).`);
|
|
201
242
|
}
|
|
202
243
|
process.exit(EXIT_OK);
|
|
203
244
|
}
|
|
204
245
|
|
|
205
246
|
if (values.json) {
|
|
206
|
-
|
|
247
|
+
writeStdoutLineSync(JSON.stringify({ run_id: runId, match: false, diffs }));
|
|
207
248
|
} else {
|
|
208
249
|
const out = [`DRIFT: ${runId} re-evaluation differs from the stored record:`];
|
|
209
250
|
for (const d of diffs) {
|
|
@@ -213,7 +254,7 @@ function runVerify(runId, values) {
|
|
|
213
254
|
out.push(` ${d.id}.${d.field}: stored=${JSON.stringify(d.stored)} fresh=${JSON.stringify(d.fresh)}`);
|
|
214
255
|
}
|
|
215
256
|
}
|
|
216
|
-
|
|
257
|
+
writeStdoutLineSync(out.join('\n'));
|
|
217
258
|
}
|
|
218
259
|
process.exit(EXIT_USER);
|
|
219
260
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fleet-instruction-scan.mjs — always-on instruction load across every repo on the host.
|
|
4
|
+
*
|
|
5
|
+
* `scripts/measure-context-overhead.sh` measures one directory precisely but
|
|
6
|
+
* costs an API call per measurement. This scans the whole fleet for free by
|
|
7
|
+
* counting bytes and classifying them the way `scripts/lib/rule-loader.mjs`
|
|
8
|
+
* does: a rule file is always-on unless its frontmatter carries `globs:` or
|
|
9
|
+
* `paths:` (issue #795), and it reaches wave agents unless it is tagged
|
|
10
|
+
* `tier: coordinator-only` (issue #692).
|
|
11
|
+
*
|
|
12
|
+
* Token estimates use the ratio measured 2026-07-30 on claude-opus-5[1m]:
|
|
13
|
+
* 108589 always-on bytes in this repo produced roughly 67396 context tokens,
|
|
14
|
+
* i.e. about 1 token per 1.6 bytes. Treat the estimate as an order of
|
|
15
|
+
* magnitude and confirm a specific repo with measure-context-overhead.sh.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node scripts/fleet-instruction-scan.mjs
|
|
19
|
+
* node scripts/fleet-instruction-scan.mjs ~/Projects/intern ~/Projects/extern
|
|
20
|
+
* node scripts/fleet-instruction-scan.mjs --json
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { basename, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const BYTES_PER_TOKEN = 1.6;
|
|
28
|
+
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const asJson = argv.includes('--json');
|
|
31
|
+
const roots = argv.filter((a) => !a.startsWith('--'));
|
|
32
|
+
const ROOTS = roots.length
|
|
33
|
+
? roots
|
|
34
|
+
: [
|
|
35
|
+
join(homedir(), 'Projects', 'extern'),
|
|
36
|
+
join(homedir(), 'Projects', 'intern'),
|
|
37
|
+
join(homedir(), 'Projects', 'Ventures'),
|
|
38
|
+
join(homedir(), 'Projects', 'Bernhard'),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** Classify one rule file the way rule-loader.mjs does. */
|
|
42
|
+
function classify(path) {
|
|
43
|
+
const body = readFileSync(path, 'utf8');
|
|
44
|
+
const fmMatch = body.match(/^---\n([\s\S]*?)\n---/);
|
|
45
|
+
const fm = fmMatch ? fmMatch[1] : '';
|
|
46
|
+
const tierMatch = fm.match(/^tier:\s*(\S+)/m);
|
|
47
|
+
return {
|
|
48
|
+
scoped: /^(globs|paths):/m.test(fm),
|
|
49
|
+
tier: tierMatch ? tierMatch[1] : 'always',
|
|
50
|
+
bytes: Buffer.byteLength(body),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function scanRepo(dir) {
|
|
55
|
+
let alwaysBytes = 0;
|
|
56
|
+
let waveBytes = 0;
|
|
57
|
+
let files = 0;
|
|
58
|
+
let scopedFiles = 0;
|
|
59
|
+
const rulesDir = join(dir, '.claude', 'rules');
|
|
60
|
+
if (existsSync(rulesDir)) {
|
|
61
|
+
for (const f of readdirSync(rulesDir).filter((n) => n.endsWith('.md'))) {
|
|
62
|
+
const c = classify(join(rulesDir, f));
|
|
63
|
+
files++;
|
|
64
|
+
if (c.scoped) {
|
|
65
|
+
scopedFiles++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
alwaysBytes += c.bytes;
|
|
69
|
+
if (c.tier !== 'coordinator-only') waveBytes += c.bytes;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const instructionFile = ['CLAUDE.md', 'AGENTS.md'].map((f) => join(dir, f)).find(existsSync);
|
|
73
|
+
const cmBytes = instructionFile ? statSync(instructionFile).size : 0;
|
|
74
|
+
return { files, scopedFiles, alwaysBytes, waveBytes, cmBytes, total: alwaysBytes + cmBytes };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const root of ROOTS) {
|
|
79
|
+
if (!existsSync(root)) continue;
|
|
80
|
+
for (const name of readdirSync(root)) {
|
|
81
|
+
const dir = join(root, name);
|
|
82
|
+
try {
|
|
83
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!existsSync(join(dir, '.git'))) continue;
|
|
88
|
+
rows.push({ repo: `${basename(root)}/${name}`, ...scanRepo(dir) });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
rows.sort((a, b) => b.total - a.total);
|
|
92
|
+
|
|
93
|
+
if (asJson) {
|
|
94
|
+
console.log(JSON.stringify({ bytesPerToken: BYTES_PER_TOKEN, repos: rows }, null, 2));
|
|
95
|
+
process.exit(0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const pad = (s, n) => String(s).padStart(n);
|
|
99
|
+
console.log(
|
|
100
|
+
'REPO'.padEnd(36),
|
|
101
|
+
pad('RULES', 6),
|
|
102
|
+
pad('SCOPED', 7),
|
|
103
|
+
pad('ALWAYS_B', 9),
|
|
104
|
+
pad('WAVE_B', 8),
|
|
105
|
+
pad('INSTR_B', 8),
|
|
106
|
+
pad('EST_TOK', 8),
|
|
107
|
+
);
|
|
108
|
+
for (const r of rows) {
|
|
109
|
+
console.log(
|
|
110
|
+
r.repo.padEnd(36),
|
|
111
|
+
pad(r.files, 6),
|
|
112
|
+
pad(r.scopedFiles, 7),
|
|
113
|
+
pad(r.alwaysBytes, 9),
|
|
114
|
+
pad(r.waveBytes, 8),
|
|
115
|
+
pad(r.cmBytes, 8),
|
|
116
|
+
pad(Math.round(r.total / BYTES_PER_TOKEN), 8),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const totalFiles = rows.reduce((a, r) => a + r.files, 0);
|
|
121
|
+
const totalScoped = rows.reduce((a, r) => a + r.scopedFiles, 0);
|
|
122
|
+
const median = (xs) => {
|
|
123
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
124
|
+
return s.length ? s[Math.floor(s.length / 2)] : 0;
|
|
125
|
+
};
|
|
126
|
+
const withRules = rows.filter((r) => r.files > 0);
|
|
127
|
+
|
|
128
|
+
console.log(`\nrepos scanned : ${rows.length} (${withRules.length} with rule files)`);
|
|
129
|
+
console.log(`rule files : ${totalFiles} (${totalScoped} glob/path-scoped, ${totalFiles - totalScoped} always-on)`);
|
|
130
|
+
console.log(`median always-on bytes : ${median(withRules.map((r) => r.alwaysBytes))}`);
|
|
131
|
+
console.log(`median est. tokens/session: ${Math.round(median(withRules.map((r) => r.total)) / BYTES_PER_TOKEN)}`);
|
|
132
|
+
|
|
133
|
+
// A value shared by many repos means a vendored rule set: trimming it at the
|
|
134
|
+
// source propagates everywhere, which is the cheapest lever in the fleet.
|
|
135
|
+
const byBytes = new Map();
|
|
136
|
+
for (const r of withRules) byBytes.set(r.alwaysBytes, (byBytes.get(r.alwaysBytes) || 0) + 1);
|
|
137
|
+
const shared = [...byBytes.entries()].filter(([, n]) => n > 1).sort((a, b) => b[1] - a[1]);
|
|
138
|
+
if (shared.length) {
|
|
139
|
+
console.log('\nidentical always-on totals (vendored rule sets — trim once, propagate widely):');
|
|
140
|
+
for (const [bytes, n] of shared.slice(0, 5)) console.log(` ${bytes} B in ${n} repos`);
|
|
141
|
+
}
|
|
@@ -22,6 +22,8 @@ import { existsSync } from 'node:fs';
|
|
|
22
22
|
import { randomUUID } from 'node:crypto';
|
|
23
23
|
import path from 'node:path';
|
|
24
24
|
|
|
25
|
+
import { filterRealSessions } from './session-schema.mjs';
|
|
26
|
+
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// Constants
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
@@ -121,13 +123,20 @@ export async function readDialecticSignals({ repoRoot } = {}) {
|
|
|
121
123
|
try {
|
|
122
124
|
const raw = await readFile(sessionsPath, 'utf8');
|
|
123
125
|
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
126
|
+
const entries = [];
|
|
124
127
|
for (const line of lines) {
|
|
125
|
-
let entry;
|
|
126
128
|
try {
|
|
127
|
-
|
|
129
|
+
entries.push(JSON.parse(line));
|
|
128
130
|
} catch {
|
|
129
131
|
continue; // malformed line — skip silently
|
|
130
132
|
}
|
|
133
|
+
}
|
|
134
|
+
// Abandoned-session filter (#834): mirrors auto-dream.mjs
|
|
135
|
+
// readDreamSignals() — phantom `status: 'abandoned'` stubs are
|
|
136
|
+
// legitimate DATA but not legitimate SIGNAL, and must not fire the
|
|
137
|
+
// /evolve --dialectic cadence off zero real work.
|
|
138
|
+
const realEntries = filterRealSessions(entries);
|
|
139
|
+
for (const entry of realEntries) {
|
|
131
140
|
const startedAt = entry.started_at;
|
|
132
141
|
if (typeof startedAt !== 'string' || startedAt.length === 0) continue;
|
|
133
142
|
if (lastRunAt === null || startedAt > lastRunAt) {
|
|
@@ -25,6 +25,8 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
|
|
28
|
+
import { filterRealSessions } from './session-schema.mjs';
|
|
29
|
+
|
|
28
30
|
// ---------------------------------------------------------------------------
|
|
29
31
|
// Signal reader — MEMORY.md size + sessions-since-last-cleanup
|
|
30
32
|
// ---------------------------------------------------------------------------
|
|
@@ -74,8 +76,16 @@ export async function readDreamSignals({ repoRoot, memoryDir }) {
|
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
+
// Abandoned-session filter (#834): sessions.jsonl carries phantom
|
|
80
|
+
// `status: 'abandoned'` stubs (session-close-backfill records for
|
|
81
|
+
// sessions that ended without a real close). They are legitimate DATA
|
|
82
|
+
// but not legitimate SIGNAL — a burst of abandoned stubs must not fire
|
|
83
|
+
// /memory-cleanup off zero real work. filterRealSessions() is the
|
|
84
|
+
// shared, tested implementation (scripts/lib/session-schema/filters.mjs).
|
|
85
|
+
const realEntries = filterRealSessions(entries);
|
|
86
|
+
|
|
87
|
+
// Find the most recent memory_cleanup_at timestamp across all REAL entries.
|
|
88
|
+
for (const entry of realEntries) {
|
|
79
89
|
const ts = entry.memory_cleanup_at;
|
|
80
90
|
if (typeof ts === 'string' && ts.length > 0) {
|
|
81
91
|
if (lastCleanupAt === null || ts > lastCleanupAt) {
|
|
@@ -84,11 +94,12 @@ export async function readDreamSignals({ repoRoot, memoryDir }) {
|
|
|
84
94
|
}
|
|
85
95
|
}
|
|
86
96
|
|
|
87
|
-
// Count entries newer than the last cleanup (or total
|
|
97
|
+
// Count REAL entries newer than the last cleanup (or total REAL entries
|
|
98
|
+
// when no cleanup ever ran).
|
|
88
99
|
if (lastCleanupAt === null) {
|
|
89
|
-
sessionsSinceCleanup =
|
|
100
|
+
sessionsSinceCleanup = realEntries.length;
|
|
90
101
|
} else {
|
|
91
|
-
for (const entry of
|
|
102
|
+
for (const entry of realEntries) {
|
|
92
103
|
const startedAt = entry.started_at;
|
|
93
104
|
if (typeof startedAt === 'string' && startedAt > lastCleanupAt) {
|
|
94
105
|
sessionsSinceCleanup += 1;
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
import { execFile as execFileCb } from 'node:child_process';
|
|
14
14
|
import { promisify } from 'node:util';
|
|
15
15
|
|
|
16
|
+
import { resolveRepoSpec } from '../vcs-repo-spec.mjs';
|
|
17
|
+
|
|
16
18
|
const realExecFile = promisify(execFileCb);
|
|
17
19
|
|
|
18
20
|
// ---------------------------------------------------------------------------
|
|
@@ -112,13 +114,22 @@ export function validateMrInputs(title, description) {
|
|
|
112
114
|
* @param {'glab'|'gh'} opts.vcs
|
|
113
115
|
* @param {string} opts.branchName
|
|
114
116
|
* @param {Function} [opts.execFile] - defaults to promisified execFile from node:child_process
|
|
117
|
+
* @param {string} [opts.repoRoot] - defaults to process.cwd()
|
|
118
|
+
* @param {(opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string | undefined} [opts.resolveRepoSpecFn]
|
|
119
|
+
* Injectable seam for the `-R`/`--repo` host-pinning resolution (#872) —
|
|
120
|
+
* defaults to the real `resolveRepoSpec` (shells out to `git remote
|
|
121
|
+
* get-url`); tests inject a stub instead of shelling out.
|
|
115
122
|
* @returns {Promise<{hasMR: boolean, mrIid: number|null, mrUrl: string|null}>}
|
|
116
123
|
*/
|
|
117
124
|
export async function checkExistingMR(opts) {
|
|
118
|
-
const { vcs, branchName } = opts;
|
|
125
|
+
const { vcs, branchName, repoRoot = process.cwd() } = opts;
|
|
119
126
|
const execFile = typeof opts.execFile === 'function' ? opts.execFile : realExecFile;
|
|
127
|
+
const resolveRepoSpecFn =
|
|
128
|
+
typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
|
|
120
129
|
|
|
121
130
|
if (vcs === 'glab') {
|
|
131
|
+
// Resolve the -R/--repo host-pinning spec ONCE for this call (#872).
|
|
132
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs: 'gitlab' });
|
|
122
133
|
const args = [
|
|
123
134
|
'mr',
|
|
124
135
|
'list',
|
|
@@ -129,6 +140,7 @@ export async function checkExistingMR(opts) {
|
|
|
129
140
|
'--output',
|
|
130
141
|
'json',
|
|
131
142
|
];
|
|
143
|
+
if (spec) args.push('-R', spec);
|
|
132
144
|
const { stdout } = await execFile('glab', args, { shell: false, timeout: 5_000 });
|
|
133
145
|
let mrs;
|
|
134
146
|
try {
|
|
@@ -148,7 +160,10 @@ export async function checkExistingMR(opts) {
|
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
if (vcs === 'gh') {
|
|
163
|
+
// Resolve the -R/--repo host-pinning spec ONCE for this call (#872).
|
|
164
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs: 'github' });
|
|
151
165
|
const args = ['pr', 'list', '--head', branchName, '--state', 'open', '--json', 'number,url'];
|
|
166
|
+
if (spec) args.push('-R', spec);
|
|
152
167
|
const { stdout } = await execFile('gh', args, { shell: false, timeout: 5_000 });
|
|
153
168
|
let prs;
|
|
154
169
|
try {
|
|
@@ -391,6 +406,11 @@ export function buildMrBody(ctx) {
|
|
|
391
406
|
* @param {object} [opts]
|
|
392
407
|
* @param {Function} [opts.execFile]
|
|
393
408
|
* @param {(level: string, msg: string) => void} [opts.log]
|
|
409
|
+
* @param {string} [opts.repoRoot] - defaults to process.cwd()
|
|
410
|
+
* @param {(opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string | undefined} [opts.resolveRepoSpecFn]
|
|
411
|
+
* Injectable seam for the `-R`/`--repo` host-pinning resolution (#872) —
|
|
412
|
+
* defaults to the real `resolveRepoSpec` (shells out to `git remote
|
|
413
|
+
* get-url`); tests inject a stub instead of shelling out.
|
|
394
414
|
* @returns {Promise<{created: boolean, deferred?: boolean, existing?: boolean, mrUrl?: string|null, error?: string}>}
|
|
395
415
|
*/
|
|
396
416
|
export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
@@ -399,6 +419,9 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
399
419
|
? opts.log
|
|
400
420
|
: (_level, _msg) => {}; // no-op default
|
|
401
421
|
const execFile = typeof opts.execFile === 'function' ? opts.execFile : realExecFile;
|
|
422
|
+
const repoRoot = typeof opts.repoRoot === 'string' ? opts.repoRoot : process.cwd();
|
|
423
|
+
const resolveRepoSpecFn =
|
|
424
|
+
typeof opts.resolveRepoSpecFn === 'function' ? opts.resolveRepoSpecFn : resolveRepoSpec;
|
|
402
425
|
|
|
403
426
|
const { draftMrPolicy, vcs, issueIid, issueTitle, branchName, parentRunId, worktreePath } = loop;
|
|
404
427
|
|
|
@@ -442,6 +465,11 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
442
465
|
worktreePath,
|
|
443
466
|
});
|
|
444
467
|
|
|
468
|
+
// Resolve the -R/--repo host-pinning spec ONCE (#872); reuse the same
|
|
469
|
+
// resolved value for both the collision check and the create call below
|
|
470
|
+
// (mirrors #839's spiral-carryover.mjs / issue-close-strip-labels.mjs idiom).
|
|
471
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs });
|
|
472
|
+
|
|
445
473
|
// Collision check — skip if MR already exists
|
|
446
474
|
let existingCheck;
|
|
447
475
|
try {
|
|
@@ -449,6 +477,7 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
449
477
|
vcs: vcsBin, // 'glab' or 'gh'
|
|
450
478
|
branchName,
|
|
451
479
|
execFile,
|
|
480
|
+
resolveRepoSpecFn: () => spec,
|
|
452
481
|
});
|
|
453
482
|
} catch (err) {
|
|
454
483
|
log('error', `mr-draft: collision check failed — ${err.message}`);
|
|
@@ -491,6 +520,7 @@ export async function maybeCreateDraftMR(loop, opts = {}) {
|
|
|
491
520
|
branchName,
|
|
492
521
|
];
|
|
493
522
|
}
|
|
523
|
+
if (spec) createArgs.push('-R', spec);
|
|
494
524
|
|
|
495
525
|
// Execute MR/PR creation
|
|
496
526
|
try {
|
|
@@ -32,7 +32,8 @@ import fs, { realpathSync } from 'node:fs';
|
|
|
32
32
|
import { runLoop } from './loop.mjs';
|
|
33
33
|
import { maybeCreateDraftMR } from './mr-draft.mjs';
|
|
34
34
|
import { validateWorkspacePath } from '../worktree/lifecycle.mjs';
|
|
35
|
-
import { acquire, release } from '../session-lock.mjs';
|
|
35
|
+
import { acquire, release, buildLockOwnerProof } from '../session-lock.mjs';
|
|
36
|
+
import { emitEvent } from '../events.mjs';
|
|
36
37
|
import { main as gcMain } from '../../gc-stale-worktrees.mjs';
|
|
37
38
|
import { SEMANTIC_ID_RE } from '../session-id.mjs';
|
|
38
39
|
|
|
@@ -65,6 +66,9 @@ import { SEMANTIC_ID_RE } from '../session-id.mjs';
|
|
|
65
66
|
* @property {string|null} killSwitch
|
|
66
67
|
* @property {string|null} killSwitchDetail
|
|
67
68
|
* @property {number} iterationsCompleted
|
|
69
|
+
* @property {boolean} fallbackToManual - Mirrors loop.mjs's `fallback_to_manual` (#905): true
|
|
70
|
+
* when the inner loop broke out with no mode recommendation instead of doing real work.
|
|
71
|
+
* The caller (autopilot-multi.mjs) MUST NOT treat a true value as a completed loop.
|
|
68
72
|
* @property {number} spiralRecoveryCount
|
|
69
73
|
* @property {string|null} commitDependency
|
|
70
74
|
* @property {boolean} abortedByCohort
|
|
@@ -128,6 +132,42 @@ function repoBasename(repoRoot) {
|
|
|
128
132
|
return path.basename(repoRoot);
|
|
129
133
|
}
|
|
130
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Render a worktree path for an event record: repo-relative, never absolute.
|
|
137
|
+
*
|
|
138
|
+
* `events.jsonl` records get quoted into issues and MR descriptions, and this
|
|
139
|
+
* repo mirrors to a PUBLIC GitHub remote — so an absolute worktree path ships
|
|
140
|
+
* the operator's home directory (`/Users/<name>/…`) into a public artefact
|
|
141
|
+
* (#957 finding 3). The forensic value of the field is "WHICH worktree", which
|
|
142
|
+
* the repo-relative form carries in full.
|
|
143
|
+
*
|
|
144
|
+
* Escaping the repo root is expected, not an error: the default worktree root
|
|
145
|
+
* is `~/.so-worktrees`, so the normal output is a `../…` chain. That form is
|
|
146
|
+
* still host-agnostic — `path.relative` strips the COMMON PREFIX, which is
|
|
147
|
+
* exactly where `/Users/<name>` lives.
|
|
148
|
+
*
|
|
149
|
+
* Pure string math: no `realpathSync`, no fs access, cannot throw. This runs on
|
|
150
|
+
* the teardown path where the worktree may already have been removed.
|
|
151
|
+
*
|
|
152
|
+
* Residual, deliberately not defended against: a repo root OUTSIDE the home
|
|
153
|
+
* directory combined with a worktree root INSIDE it (e.g. `/srv/repo` +
|
|
154
|
+
* `~/.so-worktrees`) shares no prefix, so the `../` chain descends back through
|
|
155
|
+
* `Users/<name>/`. That configuration is not reachable from this module's
|
|
156
|
+
* defaults and a heuristic scrub would be less predictable than the plain
|
|
157
|
+
* relative path.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} repoRoot absolute path to the primary repo
|
|
160
|
+
* @param {string|null|undefined} worktreePath absolute worktree path, if known
|
|
161
|
+
* @returns {string|null} repo-relative path, or null when either input is unusable
|
|
162
|
+
*/
|
|
163
|
+
export function relativeWorktreePath(repoRoot, worktreePath) {
|
|
164
|
+
if (typeof worktreePath !== 'string' || worktreePath.length === 0) return null;
|
|
165
|
+
// Without a usable base there is no way to strip the host prefix. Report
|
|
166
|
+
// "unknown" rather than fall back to the absolute path this exists to avoid.
|
|
167
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
|
|
168
|
+
return path.relative(repoRoot, worktreePath);
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
// ---------------------------------------------------------------------------
|
|
132
172
|
// setupWorktree
|
|
133
173
|
// ---------------------------------------------------------------------------
|
|
@@ -230,6 +270,9 @@ export async function setupWorktree(context, opts = {}) {
|
|
|
230
270
|
* - If `result.killSwitch` is `'stall-timeout'`: leave the worktree intact
|
|
231
271
|
* for retry. Only release the lock.
|
|
232
272
|
* - Otherwise: call `opts.gcOnExit` to clean up, then release the lock.
|
|
273
|
+
* - The lock-release outcome is evaluated and emitted as an
|
|
274
|
+
* `orchestrator.session.lock.released` / `…release_failed` breadcrumb
|
|
275
|
+
* (`caller: 'worktree-pipeline'`) into the PARENT repo's events stream (#952).
|
|
233
276
|
* - Errors during teardown are swallowed (logged to stderr). Never re-throws.
|
|
234
277
|
*
|
|
235
278
|
* @param {StoryContext} context
|
|
@@ -267,13 +310,69 @@ export async function teardownWorktree(context, result, opts = {}) {
|
|
|
267
310
|
|
|
268
311
|
// Always attempt to release the per-worktree lock.
|
|
269
312
|
if (result._lockSessionId) {
|
|
313
|
+
/** @type {{ ok: boolean, deleted?: boolean, reason?: string, verified?: boolean }|null} */
|
|
314
|
+
let releaseResult = null;
|
|
270
315
|
try {
|
|
271
|
-
|
|
316
|
+
// #987 defense-in-depth: pass the in-memory genesis proof (captured by
|
|
317
|
+
// runStoryPipeline right after acquire — same process, no file I/O)
|
|
318
|
+
// so the delete is double-gated at the fs layer. Spread-guarded:
|
|
319
|
+
// release()'s proof gate triggers on `proof !== undefined`, so a
|
|
320
|
+
// null/absent proof MUST be omitted entirely or EVERY release would
|
|
321
|
+
// be refused with 'proof-mismatch'.
|
|
322
|
+
releaseResult = release({
|
|
323
|
+
sessionId: result._lockSessionId,
|
|
324
|
+
repoRoot: result.worktreePath,
|
|
325
|
+
...(result._lockOwnerProof ? { proof: result._lockOwnerProof } : {}),
|
|
326
|
+
});
|
|
272
327
|
} catch (lockErr) {
|
|
273
328
|
console.error(
|
|
274
329
|
`worktree-pipeline: lock release error for issue #${context.issueIid}: ${lockErr?.message ?? String(lockErr)}`,
|
|
275
330
|
);
|
|
276
331
|
}
|
|
332
|
+
|
|
333
|
+
// #952 (A) — observability breadcrumb for the SECOND release() call-site.
|
|
334
|
+
// Before this, the call above was fire-and-forget: its structured result was
|
|
335
|
+
// discarded entirely, so a per-worktree lock that failed to release — or
|
|
336
|
+
// that had already vanished — left NO trace anywhere. That is the blind
|
|
337
|
+
// spot behind the "#914 lock disappears in the start chain" class for
|
|
338
|
+
// worktree-isolated runs.
|
|
339
|
+
//
|
|
340
|
+
// Emitted at the CALL-SITE, never inside release(): session-lock.mjs
|
|
341
|
+
// deliberately carries no dependency on events.mjs, and release() is
|
|
342
|
+
// synchronous while emitEvent() is async. `caller` keeps this path
|
|
343
|
+
// distinguishable from hooks/on-session-end.mjs in the single stream.
|
|
344
|
+
//
|
|
345
|
+
// Destination is PINNED to the parent repo (`repoRoot` via the #941
|
|
346
|
+
// `opts.repoRoot` interface — same pattern as scripts/lib/lock-reaper.mjs)
|
|
347
|
+
// rather than the ambient SO_PROJECT_DIR: this pipeline deliberately runs
|
|
348
|
+
// across worktrees with an explicit repoRoot (#219 CWD-drift), and the
|
|
349
|
+
// worktree itself may already have been removed by the gc step above, so
|
|
350
|
+
// its own `.orchestrator/` is not a valid destination.
|
|
351
|
+
const benignAlreadyGone = releaseResult?.ok === true && releaseResult.reason === 'no-lock';
|
|
352
|
+
const releaseFailed = releaseResult === null
|
|
353
|
+
|| (!benignAlreadyGone && (releaseResult.ok !== true || releaseResult.deleted !== true));
|
|
354
|
+
try {
|
|
355
|
+
if (releaseFailed) {
|
|
356
|
+
await emitEvent('orchestrator.session.lock.release_failed', {
|
|
357
|
+
session_id: result._lockSessionId,
|
|
358
|
+
reason: releaseResult === null
|
|
359
|
+
? 'threw'
|
|
360
|
+
: (releaseResult.reason ?? (releaseResult.ok === true ? 'not-deleted' : 'fs-error')),
|
|
361
|
+
caller: 'worktree-pipeline',
|
|
362
|
+
worktree_path: relativeWorktreePath(repoRoot, result.worktreePath),
|
|
363
|
+
issue_iid: context.issueIid,
|
|
364
|
+
}, { repoRoot });
|
|
365
|
+
} else {
|
|
366
|
+
await emitEvent('orchestrator.session.lock.released', {
|
|
367
|
+
session_id: result._lockSessionId,
|
|
368
|
+
caller: 'worktree-pipeline',
|
|
369
|
+
outcome: benignAlreadyGone ? 'already-gone' : 'deleted',
|
|
370
|
+
verified: releaseResult.verified === true,
|
|
371
|
+
worktree_path: relativeWorktreePath(repoRoot, result.worktreePath),
|
|
372
|
+
issue_iid: context.issueIid,
|
|
373
|
+
}, { repoRoot });
|
|
374
|
+
}
|
|
375
|
+
} catch { /* observability is best-effort — teardown must never throw */ }
|
|
277
376
|
}
|
|
278
377
|
}
|
|
279
378
|
|
|
@@ -393,6 +492,7 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
393
492
|
kill_switch: 'failed-wave',
|
|
394
493
|
kill_switch_detail: err?.message ?? String(err),
|
|
395
494
|
iterations_completed: 0,
|
|
495
|
+
fallback_to_manual: false,
|
|
396
496
|
stall_recovery_count: 0,
|
|
397
497
|
};
|
|
398
498
|
}
|
|
@@ -409,11 +509,18 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
409
509
|
killSwitch: loopState.kill_switch ?? null,
|
|
410
510
|
killSwitchDetail: loopState.kill_switch_detail ?? null,
|
|
411
511
|
iterationsCompleted: loopState.iterations_completed ?? 0,
|
|
512
|
+
fallbackToManual: loopState.fallback_to_manual === true,
|
|
412
513
|
spiralRecoveryCount: loopState.stall_recovery_count ?? 0,
|
|
413
514
|
commitDependency: null,
|
|
414
515
|
abortedByCohort: loopState.kill_switch === 'peer-abort',
|
|
415
516
|
// Internal: used by teardownWorktree to release the lock.
|
|
416
517
|
_lockSessionId: lockSessionId,
|
|
518
|
+
// Internal (#987): in-memory genesis proof for the proof-gated release in
|
|
519
|
+
// teardownWorktree. Same process as the acquire above, so no file I/O is
|
|
520
|
+
// needed — buildLockOwnerProof() returns null on a partial/absent lock
|
|
521
|
+
// (e.g. a DI test stub without a full lock body), and teardown's
|
|
522
|
+
// spread-guard then falls back to the session_id-only release.
|
|
523
|
+
_lockOwnerProof: buildLockOwnerProof(lockResult.lock),
|
|
417
524
|
};
|
|
418
525
|
|
|
419
526
|
await teardownWorktree(context, result, { gcOnExit });
|
|
@@ -423,11 +530,12 @@ export async function runStoryPipeline(context, opts = {}) {
|
|
|
423
530
|
throw loopError;
|
|
424
531
|
}
|
|
425
532
|
|
|
426
|
-
// Strip internal
|
|
427
|
-
const { _lockSessionId: _removed, ...publicResult } = result;
|
|
533
|
+
// Strip internal fields before returning to caller.
|
|
534
|
+
const { _lockSessionId: _removed, _lockOwnerProof: _removedProof, ...publicResult } = result;
|
|
428
535
|
|
|
429
|
-
// Suppress unused-variable lint warning —
|
|
536
|
+
// Suppress unused-variable lint warning — both are intentionally discarded.
|
|
430
537
|
void _removed;
|
|
538
|
+
void _removedProof;
|
|
431
539
|
|
|
432
540
|
return publicResult;
|
|
433
541
|
}
|