Files
msd-core/src/io.cts
Tom Boucher 9de4d67118 fix(#3579): a pointer-less session inherits the repo active-workstream marker (#3616)
* test(3579): failing-first coverage for repo-marker inheritance

A session that carries an identity but has never run 'workstream use' reads an
absent session pointer, resolves null, and composes the flat .planning tree even
when .planning/active-workstream names a live workstream. These tests fail on that
and pin the invariants the fix must not break: a session with its own pointer is
never repointed, and a session that merely lacked a pointer must never clear the
shared marker on another session's behalf.

* fix(3579): a pointer-less session inherits the repo active-workstream marker

RED proven at 157cae26: the three inheritance tests failed while every isolation and
negative control passed on base — the gap, and nothing else.

pickActiveWorkstreamAdapter returned exactly ONE adapter: the session-scoped one
whenever a session key existed, so the shared .planning/active-workstream marker was
never consulted. getWorkstreamSessionKey resolves a key from ~13 env vars or the
controlling TTY, so on any normal interactive terminal a key almost always exists —
which is why a session that had never run 'workstream use' read an absent pointer,
resolved null, and composed the FLAT planning tree even though the repo marker named a
live workstream. Reads misreported; writes corrupted the superseded flat STATE. Silent,
because the stale tree is well-formed.

This was a genuine design fork, not an oversight: references/workstream-flag.md
documented step 4 as a fallback 'when no session key exists', and the session isolation
that buys is deliberate (#2850). The issue's Agent Brief left the choice open and said
the reference doc should match whatever semantics ship. The maintainer ruled in chat for
inheritance.

Resolution now walks an ORDERED chain — session adapter first, shared second — and only
a null from the session adapter falls through to the marker. Strictly additive: it can
only turn a null into a name, never change a name that already resolves.

The dangerous part is clear() ownership. resolveFromChain treats chain[0] as owned: only
it is ever cleared, and only under selfHeal (getActiveWorkstream, never peek). An
INHERITED marker is read-only — a stale value there resolves null and the file is left
alone. Without that, one pointer-less session's read would delete the repo marker for
every other session, which is a worse bug than the one being fixed. Covered by a test
that asserts the marker still exists on disk after such a read.

peekActiveWorkstream inherits but still mutates nothing (#2850 — the statusline draws on
every render).

references/workstream-flag.md's Resolution Priority is rewritten to match, keeping the
session-isolation rationale and noting that inheritance does not weaken it: a session
that owns a pointer is never repointed.

Fixes #3579

* fix(3579): correct the guard diagnostics and lock the clear-semantics

Three review passes; every finding fixed inline.

MISSING ACCEPTANCE CRITERION (spec pass). The brief requires refusal diagnostics that
distinguish 'marker present but the session lookup missed it' from 'no workstream set at
all', and the two workstream-mode fail-safe guards were byte-for-byte untouched — still
emitting a generic 'no active workstream is set' even when a marker exists and merely
names a missing directory. Both guards (cmdPhaseComplete, cmdInitProgress) now branch on
a new read-only diagnoseUnresolvedActiveWorkstream, which reuses the SAME
resolvesToExistingWorkstream predicate resolveFromChain uses, so the diagnosis and the
resolution cannot disagree. Two typed reasons added to ERROR_REASON; both arms still
refuse — the fail-closed behavior is unchanged, only the message is now true.

REAL TEST FAILURE, not a flake. The remote run failed 'clearing one session does not
clear another session pointer'. That describe uses before() rather than beforeEach, so
one tmpDir is shared and an earlier test writes active-workstream=beta into it; under
inheritance the just-cleared session picks that marker up and resolves beta instead of
null. The failure is a CORRECT consequence of Option A surfaced through an
order-dependent fixture. The test now establishes its own marker state explicitly — its
real intent (clearing A must not disturb B's pointer) is preserved and not weakened — and
a new test pins the semantic deliberately: clearing a session pointer returns that
session to INHERITING the marker, it does not force flat mode. Documented in
references/workstream-flag.md, including how to actually get flat behavior.

Also from review: partial activeWorkstreamAdapters injection no longer silently
synthesizes a REAL filesystem adapter for the missing half (a latent test-isolation
trap); the duplicated validate-then-existsSync logic is factored into one predicate; and
the two try/finally test bodies are converted to t.after per CONTRIBUTING.

New coverage: whitespace/empty shared marker; a session whose OWN pointer is stale while
the marker names a different valid workstream (must self-heal to null, never inherit —
the isolation guarantee at its sharpest); and both new diagnostic arms asserted on
structured --json-errors output rather than prose.

* fix(3579): read resolvability with the non-mutating peek, not the self-healing resolver

Three of our own new tests failed on 7f5e706a. All three had ONE root cause, and none
was fixed by relaxing an assertion.

gsd-tools.cjs's bootstrap called the MUTATING getActiveWorkstream unconditionally on
every invocation, purely to populate routing env. On an unresolvable pointer that
self-healed — cleared it — BEFORE the dispatched command ran its own resolution. A second
read in the same process then observed already-cleared state:

- Isolation violation: a session whose own pointer was stale had it cleared by the
  bootstrap, so cmdWorkstreamGet's own resolution found a pointer-LESS session and
  inherited the shared marker ('beta' instead of null). Exactly the guarantee #2850 exists
  to protect, defeated across two calls rather than within one.
- Guard diagnostics: the guards' own truthiness check also used the mutating resolver, so
  it cleared the invalid marker and the immediately-following read-only diagnosis found
  nothing and reported none_active instead of marker_unresolved.

So a single invocation's answer depended on how many times it resolved. The bootstrap
self-heal is PRE-EXISTING and was harmless while pointer-less meant flat — inheritance is
what made it answer-changing, so this fix belongs here.

Every call site that only CHECKS resolvability — the bootstrap, both fail-safe guards'
truthiness check, and two informational init report fields — now uses the non-mutating
peekActiveWorkstream. Self-heal is unchanged in active-workstream-store and still fires
exactly once, at whichever site actually consumes the workstream.

Verified by driving the real CLI against temp fixtures, since the suite cannot run
locally: stale-own-pointer resolves null with the marker intact; both guard arms report
marker_unresolved with missing_workstream_dir / invalid_name and the marker survives;
no-marker still reports none_active; identity-less self-heal still deletes an invalid
marker byte-identically to pre-#3579; and a session with a valid own pointer still wins.

* chore(3579): backfill changeset PR number (#3616)

* test(3579): kill the surviving mutants in the new resolution code

CI's Stryker gate failed: active-workstream-store scored 79.45% against a break
threshold of 80 — 259 killed, 67 survived, at 'Ran 1.00 tests per mutant on average'.
The survivors cluster in the code this PR added (pickActiveWorkstreamAdapterChain,
resolvesToExistingWorkstream, resolveFromChain, diagnoseUnresolvedActiveWorkstream):
the CLI-level tests exercise those paths but do not DISCRIMINATE their branches, which
is precisely what a surviving mutant means.

Raised by strengthening assertions, never by touching the threshold. 21 unit tests added
to the existing unit suite, each written to fail under a specific named mutant, using the
module's injected adapter seams and createMemoryPointerAdapter so they stay hermetic
under Stryker's per-mutant reruns:

- chain shape with and without a session key, asserting length AND element identity
  (kills the if(false), the ': []' array mutant, and the block removal)
- partial adapter injection, asserting the missing half is an inert memory adapter that
  never touches the filesystem (kills the three '??' -> '&&' mutants)
- both arms of '!name || !validateWorkstreamName(name)' as SEPARATE tests — an absent
  name and a non-empty invalid one — which is what kills the '||' -> '&&' mutant
- self-heal discrimination: getActiveWorkstream must clear an unresolvable owned pointer
  and peekActiveWorkstream must not, asserted on adapter state after each
  (kills if(selfHeal) -> if(true))
- fallback arm both ways: a fallback that resolves and one that does not
- diagnoseUnresolvedActiveWorkstream asserted as a full object per case, with the reason
  strings compared exactly (kills present:true -> false and both StringLiteral mutants)

One mutant is deliberately left: 'if (chain.length === 0)' -> 'if (false)'. The branch is
structurally unreachable — the only chain source always returns a 1- or 2-element array
literal — and resolveFromChain is not exported. Killing it would mean exporting an
internal or deleting a defensive guard; neither is worth doing for a mutant, and the
score clears 80 without it. Recorded here rather than left unexplained.

Every new assertion was evaluated against the built module with real fixtures before
committing, since the suite cannot run locally.

---------

Co-authored-by: sim <sim@local>
2026-08-18 11:37:44 -04:00

267 lines
11 KiB
TypeScript

/**
* CLI I/O primitives — output(), error(), ERROR_REASON, JSON-error mode,
* and the temp-file helpers that output() depends on.
*
* Extracted from core.cts (ADR-857 rollout phase 1 / issue #859).
* The hand-written bodies are preserved byte-for-behaviour; only the module
* boundary moved. The core.cjs re-export spine was retired in epic #1267;
* callers import I/O primitives from io.cjs directly.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
// ─── Temp-file helpers (needed by output()) ──────────────────────────────────
/**
* Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
* Created on first use. Keeps GSD temp files isolated from the system
* temp directory so reap scans only GSD files (#1975).
*/
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
function ensureGsdTempDir(): void {
platformEnsureDir(GSD_TEMP_DIR);
}
interface ReapOptions {
maxAgeMs?: number;
dirsOnly?: boolean;
}
/**
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
* @param prefix - filename prefix to match (e.g., 'gsd-')
* @param opts
* @param opts.maxAgeMs - max age in ms before removal (default: 5 min)
* @param opts.dirsOnly - if true, only remove directories (default: false)
*/
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {}): void {
try {
ensureGsdTempDir();
const now = Date.now();
const entries = fs.readdirSync(GSD_TEMP_DIR);
for (const entry of entries) {
if (!entry.startsWith(prefix)) continue;
const fullPath = path.join(GSD_TEMP_DIR, entry);
try {
const stat = fs.statSync(fullPath);
if (now - stat.mtimeMs > maxAgeMs) {
if (stat.isDirectory()) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else if (!dirsOnly) {
fs.unlinkSync(fullPath);
}
}
} catch {
// File may have been removed between readdir and stat — ignore
}
}
} catch {
// Non-critical — don't let cleanup failures break output
}
}
// ─── Output helpers ───────────────────────────────────────────────────────────
/**
* Transient write errnos. When stdout/stderr is a NON-BLOCKING pipe — as it is
* under the parallel `node --test` runner on Linux CI — a full pipe buffer makes
* `fs.writeSync` throw EAGAIN, and a signal can interrupt it with EINTR. Both
* clear on retry once the reader drains. This is the same transient class the
* STATE.md lock path already retries (ACQUIRE_LOCK_RETRY_ERRNOS, #3776); #1008.
*/
const WRITE_RETRY_ERRNOS = new Set(['EAGAIN', 'EINTR']);
// Bounded so a pathological never-draining fd cannot spin forever. Each retry
// yields the thread for ~1ms via Atomics.wait (the project's sync-sleep idiom —
// see clock.cts realClock.sleep), so the cap is ~1s of total back-pressure wait.
const WRITE_MAX_RETRIES = 1000;
const WRITE_RETRY_BACKOFF_MS = 1;
// Sleep buffer is lazily allocated on the FIRST back-pressure retry (rare — only
// when a non-blocking pipe is full) and then reused. Keeping it out of module
// load costs nothing on the overwhelmingly common no-retry path and avoids
// perturbing SharedArrayBuffer-allocation accounting in other modules (perf-316).
let _writeSleepBuf: Int32Array | null = null;
function backoffOnce(): void {
if (_writeSleepBuf === null) _writeSleepBuf = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(_writeSleepBuf, 0, 0, WRITE_RETRY_BACKOFF_MS);
}
/**
* Write the entire payload to `fd`, tolerating non-blocking-pipe back-pressure.
*
* `fs.writeSync` does NOT block on a non-blocking pipe: a full buffer throws
* EAGAIN, and a partially-drained buffer returns a SHORT count (fewer bytes than
* requested). The previous bare `fs.writeSync(fd, string)` call assumed it always
* blocked until the kernel accepted every byte — false under load, which both
* threw spurious errors and risked silently truncating output (#1008).
*
* This loops on short counts (advancing the offset) and retries EAGAIN/EINTR with
* a brief Atomics.wait backoff that yields the thread so the reader can drain.
* Non-transient errors (e.g. EPIPE) propagate unchanged.
*/
function writeAllSync(fd: number, data: string): void {
const buf = Buffer.from(data, 'utf8');
let offset = 0;
let retries = 0;
while (offset < buf.length) {
try {
offset += fs.writeSync(fd, buf, offset, buf.length - offset);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (WRITE_RETRY_ERRNOS.has(code) && retries < WRITE_MAX_RETRIES) {
retries += 1;
backoffOnce();
continue;
}
throw err;
}
}
}
/**
* The wire form of a JSON result: the exact bytes `output()` emits for it.
*
* Exported because a caller that has to reason about the size of its own
* response — graphify's `--budget` accounting (#2738) — must measure the string
* this function produces rather than a second, privately-maintained
* serialization that can drift from it. One definition, so estimator and
* emitter cannot disagree about indentation or shape.
*
* The `@file:` redirection in `output()` is a transport detail, not a payload
* change: the caller still consumes these bytes, so these are the right ones to
* measure.
*/
function serializeForOutput(result: unknown): string {
return JSON.stringify(result, null, 2);
}
function output(result: unknown, raw: boolean, rawValue?: unknown): void {
let data: string;
if (raw && rawValue !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
data = String(rawValue);
} else {
const json = serializeForOutput(result);
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
if (json.length > 50000) {
reapStaleTempFiles();
ensureGsdTempDir();
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
platformWriteSync(tmpPath, json);
data = '@file:' + tmpPath;
} else {
data = json;
}
}
// process.stdout.write() is async when stdout is a pipe — process.exit()
// can tear down the process before the reader consumes the buffer. writeAllSync
// pushes every byte synchronously (looping short counts, retrying EAGAIN/EINTR),
// and skipping process.exit() lets the event loop drain naturally.
writeAllSync(1, data);
}
/**
* Frozen enum of typed reason codes used by error() for structured errors.
* Each subcommand contributes its own codes; the enum exists so tests can
* assert against typed values instead of grepping stderr (#2974).
*
* Adding a new code:
* - Pick a snake_case lowercase value (the JSON wire form)
* - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
* - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
*/
const ERROR_REASON = Object.freeze({
// config-get / config-set
CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
CONFIG_NO_FILE: 'config_no_file',
CONFIG_PARSE_FAILED: 'config_parse_failed',
CONFIG_INVALID_KEY: 'config_invalid_key',
// SDK / gsd-tools dispatch
SDK_FAIL_FAST: 'sdk_fail_fast',
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
SDK_MISSING_ARG: 'sdk_missing_arg',
// workflow / phase
PHASE_NOT_FOUND: 'phase_not_found',
PHASE_VERIFICATION_INCOMPLETE: 'phase_verification_incomplete',
PHASE_PLAN_COVERAGE_INCOMPLETE: 'phase_plan_coverage_incomplete',
SUMMARY_NO_PLANNING: 'summary_no_planning',
// #3579: workstream-mode fail-safe guards (init.progress, phase.complete) —
// distinguishes "no marker/pointer anywhere" from "a marker exists but
// didn't resolve" so a JSON-error-mode caller can branch on `reason`
// instead of regexing the human message.
WORKSTREAM_MODE_NONE_ACTIVE: 'workstream_mode_none_active',
WORKSTREAM_MODE_MARKER_UNRESOLVED: 'workstream_mode_marker_unresolved',
// graphify
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
// hooks
HOOKS_OPT_OUT: 'hooks_opt_out',
// commit-docs-guard (#3588)
COMMIT_DOCS_GUARD_NOT_A_REPO: 'commit_docs_guard_not_a_repo',
COMMIT_DOCS_GUARD_FOREIGN_HOOK: 'commit_docs_guard_foreign_hook',
COMMIT_DOCS_GUARD_HOOKS_PATH_SET: 'commit_docs_guard_hooks_path_set',
// security-scan
SECURITY_SCAN_FAILED: 'security_scan_failed',
// generic
USAGE: 'usage',
UNKNOWN: 'unknown',
});
type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON];
/**
* Process-level flag: when true, error() emits structured JSON to stderr
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
* assertions by passing that flag and parsing the JSON.
*
* Default off so existing callers and human operators keep their plain-text
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
*/
let _jsonErrorMode = false;
function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; }
function getJsonErrorMode(): boolean { return _jsonErrorMode; }
/**
* Emit an error and exit. When the second argument is provided it must be
* a value from ERROR_REASON; tests can assert on `result.reason`. When the
* process is in JSON-error mode, stderr receives `{ ok: false, reason,
* message }` so callers can parse it; otherwise stderr keeps the plain
* text form for human operators.
*
* `extra` (optional) lets a caller attach additional structured fields
* (e.g. `{ verification_stale_check_indeterminate: true }`) onto the
* JSON-error-mode payload, spread alongside `ok`/`reason`/`message`, so a
* test can assert on the value directly instead of regexing `message`'s
* human-readable text. Ignored entirely in plain-text mode — the human
* message is the only thing an operator sees there.
*/
function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN, extra?: Record<string, unknown>): never {
if (_jsonErrorMode) {
const payload = JSON.stringify({ ok: false, reason, message, ...(extra || {}) }) + '\n';
writeAllSync(2, payload);
} else {
writeAllSync(2, 'Error: ' + message + '\n');
}
process.exit(1);
}
export = {
GSD_TEMP_DIR,
ensureGsdTempDir,
reapStaleTempFiles,
output,
serializeForOutput,
ERROR_REASON,
setJsonErrorMode,
getJsonErrorMode,
error,
};