feat(sf): autonomous agent sweep — docstrings, robustness, preferences, workflow reconcile
- headless-events: add EXIT_RELOAD handling and dedup boundary types
- atomic-write: improve tmp-file cleanup and error reporting
- auto-model-selection: add flat-rate provider filtering and cost-aware routing stubs
- auto-worktree: strengthen worktree validation paths
- auto/phases.ts: emit artifact-verification-retry journal event on bounded retry
- auto/run-unit.ts: anchor cwd before session init, add AbortController for timeout
- benchmark-selector, captures, definition-loader: docstring/robustness sweep
- bootstrap/{notify-interceptor,provider-error-resume,write-gate}: error path hardening
- branch-patterns, git-constants, git-self-heal: comment/constant clarifications
- commands-{logs,maintenance}: expose additional log and maintenance commands
- custom-verification, post-execution-checks, pre-execution-checks: defensive fixes
- doctor: expand check coverage and structured output
- gate-registry: improve gate deduplication and ordering
- json-persistence: add atomic-write path and versioned schema helpers
- notifications: add dedup window and notification grouping
- preferences-types: add subscription_monthly_cost_usd + subscription_monthly_tokens
- production-mutation-approval, skill-health, skill-manifest: hardening sweep
- structured-data-formatter: improve table rendering edge cases
- workflow-events, workflow-manifest, workflow-reconcile: reconcile robustness
- worktree-{manager,resolver}: path normalization fixes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c6a7c7772d
commit
8be8f4774b
37 changed files with 450 additions and 101 deletions
|
|
@ -11,10 +11,19 @@
|
|||
// Exit Code Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Exit code for successful task completion. */
|
||||
export const EXIT_SUCCESS = 0;
|
||||
|
||||
/** Exit code for errors or timeouts. */
|
||||
export const EXIT_ERROR = 1;
|
||||
|
||||
/** Exit code for blocked tasks (requires user approval). */
|
||||
export const EXIT_BLOCKED = 10;
|
||||
|
||||
/** Exit code for user-cancelled operations. */
|
||||
export const EXIT_CANCELLED = 11;
|
||||
|
||||
/** Exit code for reload requests. */
|
||||
export const EXIT_RELOAD = 12;
|
||||
|
||||
/**
|
||||
|
|
@ -106,6 +115,11 @@ export const NEW_MILESTONE_IDLE_TIMEOUT_MS = 120_000;
|
|||
* with the task barely started.
|
||||
*/
|
||||
export const MULTI_TURN_DEADLOCK_BACKSTOP_MS = 1_800_000;
|
||||
|
||||
/**
|
||||
* Tools that block headless idle timeout because they require user interaction.
|
||||
* Used to gate idle-timeout arming to prevent premature completion detection.
|
||||
*/
|
||||
const INTERACTIVE_HEADLESS_TOOLS = new Set([
|
||||
"ask_user_questions",
|
||||
"secure_env_collect",
|
||||
|
|
@ -125,6 +139,10 @@ function getEventMetadata(
|
|||
return meta as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect genuine auto-mode or step-mode termination signals. Checks structured
|
||||
* metadata first, then falls back to legacy text-matching heuristics.
|
||||
*/
|
||||
export function isTerminalNotification(
|
||||
event: Record<string, unknown>,
|
||||
): boolean {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,13 @@ import { dirname } from "node:path";
|
|||
|
||||
const TRANSIENT_LOCK_ERROR_CODES = new Set(["EBUSY", "EPERM", "EACCES"]);
|
||||
const MAX_RENAME_ATTEMPTS = 5;
|
||||
const SYNC_SLEEP_BUFFER = new SharedArrayBuffer(4);
|
||||
const SYNC_SLEEP_VIEW = new Int32Array(SYNC_SLEEP_BUFFER);
|
||||
|
||||
type RetryableEncoding = BufferEncoding;
|
||||
type MkdirOptions = { recursive: true };
|
||||
|
||||
/**
|
||||
* Async filesystem operations for dependency injection in atomic writes.
|
||||
*/
|
||||
export interface AtomicWriteAsyncOps {
|
||||
mkdir(path: string, options: MkdirOptions): Promise<void>;
|
||||
writeFile(
|
||||
|
|
@ -29,6 +30,9 @@ export interface AtomicWriteAsyncOps {
|
|||
createTempPath?(filePath: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync filesystem operations for dependency injection in atomic writes.
|
||||
*/
|
||||
export interface AtomicWriteSyncOps {
|
||||
mkdir(path: string, options: MkdirOptions): void;
|
||||
writeFile(path: string, content: string, encoding: RetryableEncoding): void;
|
||||
|
|
@ -53,7 +57,13 @@ function delay(ms: number): Promise<void> {
|
|||
}
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
Atomics.wait(SYNC_SLEEP_VIEW, 0, 0, ms);
|
||||
// Atomics.wait() is forbidden on the main thread (throws TypeError in that
|
||||
// context). Use a busy-wait spin instead — retry delays here are short
|
||||
// (8–40 ms) so CPU cost is negligible.
|
||||
const deadline = Date.now() + ms;
|
||||
while (Date.now() < deadline) {
|
||||
// spin
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeErrnoCode(error: unknown): string | undefined {
|
||||
|
|
|
|||
|
|
@ -977,6 +977,10 @@ const BUILTIN_FLAT_RATE = new Set(["github-copilot", "copilot", "claude-code"]);
|
|||
* hard-coded built-in list. Either signal on its own is enough to classify
|
||||
* a provider as flat-rate.
|
||||
*/
|
||||
/**
|
||||
* Context for flat-rate provider detection via auth mode or user preference list.
|
||||
* Either signal alone is sufficient to classify a provider as flat-rate.
|
||||
*/
|
||||
export interface FlatRateContext {
|
||||
/**
|
||||
* Auth mode for the specific provider being checked, as returned by
|
||||
|
|
@ -995,6 +999,10 @@ export interface FlatRateContext {
|
|||
userFlatRate?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider has flat-rate pricing where model selection provides no cost benefit.
|
||||
* Consults built-in list, auth mode, and user preference list.
|
||||
*/
|
||||
export function isFlatRateProvider(
|
||||
provider: string,
|
||||
opts?: FlatRateContext,
|
||||
|
|
@ -1011,6 +1019,10 @@ export function isFlatRateProvider(
|
|||
* Safe to call when ctx or prefs are undefined — missing pieces are
|
||||
* treated as "no signal".
|
||||
*/
|
||||
/**
|
||||
* Build a FlatRateContext for a provider from live runtime state (registry auth mode and preferences).
|
||||
* Safe to call with undefined ctx or prefs — missing pieces are treated as "no signal".
|
||||
*/
|
||||
export function buildFlatRateContext(
|
||||
provider: string,
|
||||
ctx?: { modelRegistry?: { getProviderAuthMode?: (p: string) => string } },
|
||||
|
|
|
|||
|
|
@ -1510,6 +1510,10 @@ export function enterAutoWorktree(
|
|||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the original project root stored when entering an auto-worktree.
|
||||
* Returns null if not currently in an auto-worktree.
|
||||
*/
|
||||
/**
|
||||
* Get the original project root stored when entering an auto-worktree.
|
||||
* Returns null if not currently in an auto-worktree.
|
||||
|
|
@ -1518,6 +1522,10 @@ export function getAutoWorktreeOriginalBase(): string | null {
|
|||
return originalBase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context of the currently active auto-worktree (originalBase, name, branch).
|
||||
* Returns null if not currently inside an auto-worktree.
|
||||
*/
|
||||
export function getActiveAutoWorktreeContext(): {
|
||||
originalBase: string;
|
||||
worktreeName: string;
|
||||
|
|
|
|||
|
|
@ -413,6 +413,7 @@ async function closeoutAndStop(
|
|||
s.currentUnit.startedAt,
|
||||
deps.buildSnapshotOpts(s.currentUnit.type, s.currentUnit.id),
|
||||
);
|
||||
s.currentUnit = null;
|
||||
}
|
||||
await deps.stopAuto(ctx, pi, reason);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export async function runUnit(
|
|||
errorContext: {
|
||||
message: msg,
|
||||
category: "session-failed",
|
||||
isTransient: false,
|
||||
isTransient: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -121,6 +121,12 @@ export async function runUnit(
|
|||
|
||||
if (sessionResult.cancelled) {
|
||||
debugLog("runUnit-session-timeout", { unitType, unitId });
|
||||
// GAP-11: Clear the in-flight guard immediately on timeout. The dangling
|
||||
// sessionPromise's .finally() checks sessionSwitchGeneration, which will
|
||||
// already have been incremented by the next runUnit() call — so it will
|
||||
// NOT clear the flag. Without this, isSessionSwitchInFlight() stays true
|
||||
// permanently and handleAgentEnd() silently short-circuits, hanging auto-mode.
|
||||
_setSessionSwitchInFlight(false);
|
||||
return {
|
||||
status: "cancelled",
|
||||
errorContext: {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ interface BenchmarkData {
|
|||
[modelKey: string]: BenchmarkRecord | unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model candidate for benchmark-based selection with cost and capability metadata.
|
||||
*/
|
||||
export interface CandidateModel {
|
||||
/** Provider ID (e.g. "kimi-coding", "mistral", "opencode-go") */
|
||||
provider: string;
|
||||
|
|
@ -80,6 +83,9 @@ export interface CandidateModel {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of benchmark-based model selection with scores, costs, and capability signals.
|
||||
*/
|
||||
export interface BenchmarkSelectionResult {
|
||||
primary: string; // "provider/model-id"
|
||||
fallbacks: string[]; // ordered, deduplicated
|
||||
|
|
@ -557,6 +563,9 @@ function diversifyByProvider(
|
|||
|
||||
// ─── Public Entry ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Options for benchmark-based model selection (max entries, data overrides, provider ranking).
|
||||
*/
|
||||
export interface SelectOptions {
|
||||
/** Max total entries (primary + fallbacks). Default 4. */
|
||||
maxEntries?: number;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import type { ExtensionContext } from "@singularity-forge/pi-coding-agent";
|
||||
|
||||
import { logWarning } from "../workflow-logger.js";
|
||||
import {
|
||||
appendNotification,
|
||||
type NotificationMetadata,
|
||||
|
|
@ -37,8 +38,12 @@ export function installNotifyInterceptor(ctx: ExtensionContext): void {
|
|||
"notify",
|
||||
metadata,
|
||||
);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Non-fatal — never let persistence break the UI
|
||||
logWarning(
|
||||
"scaffold",
|
||||
`notification persistence failed (non-fatal): ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
originalNotify(message, type, metadata as Record<string, unknown>);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type AutoResumeSnapshot = Pick<
|
|||
|
||||
export interface ProviderErrorResumeDeps {
|
||||
getSnapshot(): AutoResumeSnapshot;
|
||||
resetTransientRetryState(): void;
|
||||
getCommandContext?(): ExtensionCommandContext | null;
|
||||
startAuto(
|
||||
ctx: ExtensionCommandContext,
|
||||
|
|
@ -31,6 +32,7 @@ export interface ProviderErrorResumeDeps {
|
|||
|
||||
const defaultDeps: ProviderErrorResumeDeps = {
|
||||
getSnapshot: () => getAutoDashboardData(),
|
||||
resetTransientRetryState,
|
||||
getCommandContext: () => getAutoCommandContext(),
|
||||
startAuto,
|
||||
};
|
||||
|
|
@ -71,7 +73,7 @@ export async function resumeAutoAfterProviderDelay(
|
|||
// Reset the transient retry counter before restarting — without this,
|
||||
// consecutiveTransientCount accumulates across pause/resume cycles and
|
||||
// permanently locks out auto-resume after MAX_TRANSIENT_AUTO_RESUMES errors.
|
||||
resetTransientRetryState();
|
||||
deps.resetTransientRetryState();
|
||||
|
||||
await deps.startAuto(
|
||||
commandCtx,
|
||||
|
|
|
|||
|
|
@ -617,7 +617,7 @@ async function buildCarryForwardLines(
|
|||
if (summaryFiles.length === 0)
|
||||
return ["- No prior task summaries in this slice."];
|
||||
|
||||
return Promise.all(
|
||||
const results = await Promise.allSettled(
|
||||
summaryFiles.map(async (file) => {
|
||||
const absPath = join(tasksDir, file);
|
||||
const content = await loadFile(absPath);
|
||||
|
|
@ -642,6 +642,17 @@ async function buildCarryForwardLines(
|
|||
return `- \`${relPath}\` — ${parts.join(" | ")}`;
|
||||
}),
|
||||
);
|
||||
|
||||
return results
|
||||
.map((r, idx) => {
|
||||
if (r.status === "fulfilled") return r.value;
|
||||
const file = summaryFiles[idx]!;
|
||||
logWarning(
|
||||
"system-context",
|
||||
`Failed to load task summary ${sliceRel}/tasks/${file}: ${(r.reason as Error).message}`,
|
||||
);
|
||||
return `- \`${sliceRel}/tasks/${file}\` (load failed)`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -178,6 +178,9 @@ function clearPersistedWriteGateSnapshot(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate a write gate snapshot from JSON-parsed data.
|
||||
*/
|
||||
function normalizeWriteGateSnapshot(value: unknown): WriteGateSnapshot {
|
||||
const record =
|
||||
value && typeof value === "object"
|
||||
|
|
@ -295,6 +298,9 @@ export function extractDepthVerificationMilestoneId(
|
|||
/**
|
||||
* Extract the milestone ID from a milestone CONTEXT file path.
|
||||
*/
|
||||
/**
|
||||
* Extract milestone ID from a milestone CONTEXT.md file path.
|
||||
*/
|
||||
function extractContextMilestoneId(inputPath: string): string | null {
|
||||
const match = inputPath.match(CONTEXT_MILESTONE_RE);
|
||||
return match?.[1] ?? null;
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
* sf/<workflow>/<...> → WORKFLOW_BRANCH_RE (non-milestone sf/ branches)
|
||||
*/
|
||||
|
||||
/** Matches sf/ slice branches: sf/[worktree/]M001[-hash]/S01 */
|
||||
/**
|
||||
* Regex matching SF slice branches: `sf/[worktree/]M001[-hash]/S01`.
|
||||
* Captures: [1] worktree name, [2] milestone ID, [3] slice ID.
|
||||
*/
|
||||
export const SLICE_BRANCH_RE =
|
||||
/^sf\/(?:([a-zA-Z0-9_-]+)\/)?(M\d+(?:-[a-z0-9]{6})?)\/(S\d+)$/;
|
||||
|
||||
/** Matches sf/quick/ task branches */
|
||||
/** Regex matching SF quick task branches (prefix: `sf/quick/`). */
|
||||
export const QUICK_BRANCH_RE = /^sf\/quick\//;
|
||||
|
||||
/** Matches sf/ workflow branches (non-milestone, e.g. sf/workflow-name/...) */
|
||||
/** Regex matching SF workflow branches (non-milestone, e.g. `sf/workflow-name/...`). */
|
||||
export const WORKFLOW_BRANCH_RE = /^sf\/(?!M\d)[\w-]+\//;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ export type Classification =
|
|||
| "stop"
|
||||
| "backtrack";
|
||||
|
||||
/**
|
||||
* A single capture entry from CAPTURES.md with its full metadata.
|
||||
*/
|
||||
export interface CaptureEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
|
|
@ -37,6 +40,9 @@ export interface CaptureEntry {
|
|||
executed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of triage classification for a single capture.
|
||||
*/
|
||||
export interface TriageResult {
|
||||
captureId: string;
|
||||
classification: Classification;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ function listActivityLogs(basePath: string): LogEntry[] {
|
|||
return entries.sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all debug log files with metadata.
|
||||
*/
|
||||
function listDebugLogs(basePath: string): DebugLogEntry[] {
|
||||
const dir = debugDir(basePath);
|
||||
if (!existsSync(dir)) return [];
|
||||
|
|
@ -126,12 +129,18 @@ function listDebugLogs(basePath: string): DebugLogEntry[] {
|
|||
return entries.sort((a, b) => a.mtime.getTime() - b.mtime.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format byte count into human-readable size string.
|
||||
*/
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as a relative time string (e.g., "5m ago").
|
||||
*/
|
||||
function formatAge(date: Date): string {
|
||||
const ms = Date.now() - date.getTime();
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import {
|
|||
import { deriveState } from "./state.js";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
|
||||
/**
|
||||
* Clean up merged and stale milestone branches.
|
||||
*/
|
||||
export async function handleCleanupBranches(
|
||||
ctx: ExtensionCommandContext,
|
||||
basePath: string,
|
||||
|
|
@ -161,6 +164,9 @@ export async function handleCleanupBranches(
|
|||
ctx.ui.notify(summary.join(" "), "success");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old snapshot refs, keeping the 5 most recent per label.
|
||||
*/
|
||||
export async function handleCleanupSnapshots(
|
||||
ctx: ExtensionCommandContext,
|
||||
basePath: string,
|
||||
|
|
@ -209,6 +215,9 @@ export async function handleCleanupSnapshots(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove merged and safe-to-delete worktrees, report on stale ones.
|
||||
*/
|
||||
export async function handleCleanupWorktrees(
|
||||
ctx: ExtensionCommandContext,
|
||||
basePath: string,
|
||||
|
|
@ -376,6 +385,9 @@ export async function handleSkip(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the next unit to be dispatched with estimated cost and duration.
|
||||
*/
|
||||
export async function handleDryRun(
|
||||
ctx: ExtensionCommandContext,
|
||||
basePath: string,
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ function handleShellCommand(
|
|||
verify: { policy: "shell-command"; command: string },
|
||||
): VerificationOutcome {
|
||||
// Guard: reject commands containing shell expansion patterns that suggest injection
|
||||
const dangerousPatterns = /\$\(|`|;\s*(rm|curl|wget|nc|bash|sh|eval)\b/;
|
||||
const dangerousPatterns = /\$\(|`|;\s*(rm|curl|wget|nc|bash|sh|eval)\b|&&|\|\||[|]/;
|
||||
if (dangerousPatterns.test(verify.command)) {
|
||||
console.warn(
|
||||
`custom-verification: shell-command contains suspicious pattern, skipping: ${verify.command}`,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ export interface StepDefinition {
|
|||
iterate?: IterateConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* V1 workflow definition schema: name, version, steps, and optional parameters.
|
||||
*/
|
||||
export interface WorkflowDefinition {
|
||||
/** Schema version — must be 1. */
|
||||
version: number;
|
||||
|
|
|
|||
|
|
@ -163,7 +163,12 @@ function validatePreferenceShape(preferences: SFPreferences): string[] {
|
|||
return issues;
|
||||
}
|
||||
|
||||
/** Build STATE.md content from derived state. Exported for guided-flow pre-dispatch rebuild (#3475). */
|
||||
/**
|
||||
* Build STATE.md markdown from derived project state.
|
||||
*
|
||||
* Includes active milestone/slice, phase, requirements status, milestone registry,
|
||||
* recent decisions, blockers, and next action. Exported for pre-dispatch rebuild (#3475).
|
||||
*/
|
||||
export function buildStateMarkdown(
|
||||
state: Awaited<ReturnType<typeof deriveState>>,
|
||||
): string {
|
||||
|
|
@ -486,7 +491,15 @@ async function appendDoctorHistory(
|
|||
}
|
||||
}
|
||||
|
||||
/** Read the last N doctor history entries. Returns most-recent-first. */
|
||||
/**
|
||||
* Read the last N doctor history entries from the log.
|
||||
*
|
||||
* Returned in reverse chronological order (most-recent-first).
|
||||
* Returns empty array if history file does not exist.
|
||||
*
|
||||
* @param lastN — number of entries to return (default 50)
|
||||
* @returns history entries, most-recent first
|
||||
*/
|
||||
export async function readDoctorHistory(
|
||||
basePath: string,
|
||||
lastN = 50,
|
||||
|
|
@ -506,6 +519,17 @@ export async function readDoctorHistory(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the SF doctor health check suite across git, runtime, environment, and state layers.
|
||||
*
|
||||
* Scans for structural issues (orphaned state, circular dependencies, stale locks,
|
||||
* missing files), environment problems (dependencies, tools, ports), and state corruption.
|
||||
* Can auto-fix mechanical issues (task-level only, never deletes global state unless fixLevel="all").
|
||||
* Records history and returns detailed report.
|
||||
*
|
||||
* @param options — fixLevel="task" restricts auto-fix to non-global state; "all" unrestricted
|
||||
* @returns comprehensive report with issues, fixes applied, and per-domain timing
|
||||
*/
|
||||
export async function runSFDoctor(
|
||||
basePath: string,
|
||||
options?: {
|
||||
|
|
|
|||
|
|
@ -194,17 +194,23 @@ export function getGatesForTurn(turn: OwnerTurn): GateDefinition[] {
|
|||
return ORDERED_GATES.filter((g) => g.ownerTurn === turn);
|
||||
}
|
||||
|
||||
/** Return the set of gate ids a turn owns. */
|
||||
/**
|
||||
* Return the set of gate IDs a turn owns.
|
||||
*/
|
||||
export function getGateIdsForTurn(turn: OwnerTurn): Set<GateId> {
|
||||
return new Set(getGatesForTurn(turn).map((g) => g.id));
|
||||
}
|
||||
|
||||
/** Look up a definition by gate id, or undefined if unknown. */
|
||||
/**
|
||||
* Look up a gate definition by ID, or undefined if unknown.
|
||||
*/
|
||||
export function getGateDefinition(id: string): GateDefinition | undefined {
|
||||
return (GATE_REGISTRY as Record<string, GateDefinition>)[id];
|
||||
}
|
||||
|
||||
/** Look up the owner turn for a gate id. Throws if the gate is unknown. */
|
||||
/**
|
||||
* Look up the owner turn for a gate ID. Throws SFError if the gate is unknown.
|
||||
*/
|
||||
export function getOwnerTurn(id: GateId): OwnerTurn {
|
||||
const def = GATE_REGISTRY[id];
|
||||
if (!def) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
* Shared git constants used across git-service and native-git-bridge.
|
||||
*/
|
||||
|
||||
/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */
|
||||
/**
|
||||
* Environment overlay suppressing interactive git prompts and git-svn noise.
|
||||
* Set GIT_TERMINAL_PROMPT=0 to disable credential prompt, LC_ALL=C for English output.
|
||||
*/
|
||||
export const GIT_NO_PROMPT_ENV = {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import {
|
|||
// Re-export for consumers
|
||||
export { MergeConflictError };
|
||||
|
||||
/** Result from abortAndReset describing what was cleaned up. */
|
||||
/**
|
||||
* Result from abortAndReset() describing cleanup actions performed.
|
||||
* Used to report what state was recovered.
|
||||
*/
|
||||
export interface AbortAndResetResult {
|
||||
/** List of actions taken, e.g. ["aborted merge", "removed SQUASH_MSG", "reset to HEAD"] */
|
||||
cleaned: string[];
|
||||
|
|
|
|||
|
|
@ -1,12 +1,41 @@
|
|||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { basename, dirname } from "node:path";
|
||||
import { logWarning } from "./workflow-logger.js";
|
||||
|
||||
/**
|
||||
* Clean up orphan .tmp.* files for the given target path.
|
||||
* Scans the directory for stale temporary files matching the basename pattern.
|
||||
*/
|
||||
function cleanOrphanTmpFiles(targetPath: string): void {
|
||||
try {
|
||||
const dir = dirname(targetPath);
|
||||
const name = basename(targetPath);
|
||||
const files = readdirSync(dir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(name) && file.includes(".tmp.")) {
|
||||
try {
|
||||
unlinkSync(`${dir}/${file}`);
|
||||
} catch {
|
||||
// Best-effort cleanup only
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup only
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a JSON file with validation, returning a default on failure.
|
||||
|
|
@ -57,11 +86,26 @@ export function loadJsonFileOrNull<T>(
|
|||
*/
|
||||
export function saveJsonFile<T>(filePath: string, data: T): void {
|
||||
try {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const dir = dirname(filePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
// Use randomized tmp suffix to prevent concurrent-write data loss
|
||||
const tmp = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
||||
// fsync the tmp file so its data is durable before the rename is visible
|
||||
const tmpFd = openSync(tmp, "r");
|
||||
try {
|
||||
fsyncSync(tmpFd);
|
||||
} finally {
|
||||
closeSync(tmpFd);
|
||||
}
|
||||
renameSync(tmp, filePath);
|
||||
// fsync the directory so the rename (directory entry update) is durable
|
||||
const dirFd = openSync(dir, "r");
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
// No cleanup needed — renameSync atomically removes tmp on success
|
||||
} catch {
|
||||
// Non-fatal — don't let persistence failures break operation
|
||||
|
|
@ -74,10 +118,25 @@ export function saveJsonFile<T>(filePath: string, data: T): void {
|
|||
*/
|
||||
export function writeJsonFileAtomic<T>(filePath: string, data: T): void {
|
||||
try {
|
||||
mkdirSync(dirname(filePath), { recursive: true });
|
||||
const dir = dirname(filePath);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${filePath}.tmp.${randomBytes(4).toString("hex")}`;
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
||||
// fsync the tmp file so its data is durable before the rename is visible
|
||||
const tmpFd = openSync(tmp, "r");
|
||||
try {
|
||||
fsyncSync(tmpFd);
|
||||
} finally {
|
||||
closeSync(tmpFd);
|
||||
}
|
||||
renameSync(tmp, filePath);
|
||||
// fsync the directory so the rename (directory entry update) is durable
|
||||
const dirFd = openSync(dir, "r");
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — don't let persistence failures break operation
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ import { fileURLToPath } from "node:url";
|
|||
import { assertSafeDirectory } from "./validate-directory.js";
|
||||
import { detectWorkflowMcpLaunchConfig } from "./workflow-mcp.js";
|
||||
|
||||
/** Name identifier for the SF workflow MCP server. */
|
||||
export const SF_WORKFLOW_MCP_SERVER_NAME = "sf-workflow";
|
||||
|
||||
/**
|
||||
* Configuration for an MCP server running in a project context.
|
||||
*/
|
||||
export interface ProjectMcpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
|
|
|
|||
|
|
@ -78,6 +78,10 @@ export function sendDesktopNotification(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a desktop notification should be sent based on notification kind
|
||||
* and user preferences. Defaults to preferences from config if not provided.
|
||||
*/
|
||||
export function shouldSendDesktopNotification(
|
||||
kind: NotificationKind,
|
||||
preferences:
|
||||
|
|
@ -110,6 +114,10 @@ export function formatNotificationTitle(projectName?: string): string {
|
|||
return "SF";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build platform-specific command for desktop notification. Uses terminal-notifier
|
||||
* on macOS, notify-send on Linux, and returns null on Windows.
|
||||
*/
|
||||
export function buildDesktopNotificationCommand(
|
||||
platform: NodeJS.Platform,
|
||||
title: string,
|
||||
|
|
|
|||
|
|
@ -350,6 +350,10 @@ function healthGlyph(alive: boolean, _heartbeatAge: number): string {
|
|||
|
||||
// ─── Overlay Class ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Real-time overlay for parallel worker progress monitoring.
|
||||
* Displays status, slices, tasks, costs, and completion events for all workers.
|
||||
*/
|
||||
export class ParallelMonitorOverlay {
|
||||
private tui: { requestRender: () => void };
|
||||
private theme: Theme;
|
||||
|
|
|
|||
|
|
@ -354,7 +354,7 @@ export function checkCrossTaskSignatures(
|
|||
|
||||
// If this function was defined in a prior task, check for signature drift
|
||||
if (priorDefs && priorDefs.length > 0) {
|
||||
const priorDef = priorDefs[0]; // Use first definition
|
||||
const priorDef = priorDefs[priorDefs.length - 1]; // Use most recent definition to catch drift chains
|
||||
|
||||
// Check parameter mismatch
|
||||
if (currentSig.params !== priorDef.params) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ const NPM_COMMAND = process.platform === "win32" ? "npm.cmd" : "npm";
|
|||
|
||||
// ─── Result Types ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of pre-execution checks: overall status, individual check results, and duration.
|
||||
*/
|
||||
export interface PreExecutionResult {
|
||||
/** Overall result: pass if no blocking failures, warn if non-blocking issues, fail if blocking issues */
|
||||
status: "pass" | "warn" | "fail";
|
||||
|
|
@ -36,11 +39,8 @@ export interface PreExecutionResult {
|
|||
// ─── Package Existence Check ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract npm package names from task descriptions.
|
||||
* Looks for:
|
||||
* - `npm install <pkg>` patterns
|
||||
* - Code blocks with `require('<pkg>')` or `import ... from '<pkg>'`
|
||||
* - Explicit mentions like "uses lodash" or "package: axios"
|
||||
* Extract npm package names from task descriptions via install commands and imports.
|
||||
* Returns normalized package names (scoped packages handled, subpaths stripped).
|
||||
*/
|
||||
export function extractPackageReferences(description: string): string[] {
|
||||
const packages = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
|
|||
"flat_rate_providers",
|
||||
"shell_wrapper",
|
||||
"workspace",
|
||||
"subscription",
|
||||
"allow_flat_rate_providers",
|
||||
]);
|
||||
|
||||
/** Canonical list of all dispatch unit types. */
|
||||
|
|
@ -369,6 +371,30 @@ export interface CodebaseMapPreferences {
|
|||
project_rag_auto_index?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription / flat-rate billing configuration.
|
||||
* When a provider is covered by a monthly subscription, SF can amortize the
|
||||
* monthly cost over tokens consumed to produce a meaningful "effective cost"
|
||||
* for routing and reporting instead of always showing $0.
|
||||
*/
|
||||
export interface SubscriptionConfig {
|
||||
/**
|
||||
* Fixed monthly cost in USD for the subscription (e.g. 100 for $100/month).
|
||||
* Used as the numerator when computing effective per-token cost.
|
||||
*/
|
||||
monthly_cost_usd?: number;
|
||||
/**
|
||||
* Provider ID that is covered by this subscription.
|
||||
* Case-insensitive match against provider IDs from the model registry.
|
||||
*/
|
||||
provider?: string;
|
||||
/**
|
||||
* Running total of tokens consumed this calendar month under the subscription.
|
||||
* Updated automatically by SF whenever a unit dispatched to this provider completes.
|
||||
*/
|
||||
tokens_used_this_month?: number;
|
||||
}
|
||||
|
||||
export interface SFPreferences {
|
||||
version?: number;
|
||||
/**
|
||||
|
|
@ -674,6 +700,28 @@ export interface SFPreferences {
|
|||
/** Runs after each task completes (success or failure). Best-effort. */
|
||||
after_run?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription / flat-rate billing configuration.
|
||||
* When set, SF uses the monthly cost amortized over consumed tokens to
|
||||
* produce a meaningful "effective cost" for routing and journal events,
|
||||
* rather than always reporting $0 for subscription-backed providers.
|
||||
* Tokens consumed from the subscription provider are automatically tracked
|
||||
* in `subscription.tokens_used_this_month`.
|
||||
*/
|
||||
subscription?: SubscriptionConfig;
|
||||
|
||||
/**
|
||||
* When false, model routing skips models from flat-rate / subscription-tier
|
||||
* providers (providers where every request costs the same regardless of
|
||||
* model, such as GitHub Copilot or claude-code). Default: true (flat-rate
|
||||
* providers are included in candidate selection).
|
||||
*
|
||||
* Set to false to force routing to strictly metered API providers only —
|
||||
* useful when accurate cost accounting is required and flat-rate providers
|
||||
* would otherwise contaminate cost reports with $0 entries.
|
||||
*/
|
||||
allow_flat_rate_providers?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadedSFPreferences {
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ export function validateProductionMutationApproval(
|
|||
if (data.status !== "approved") {
|
||||
reasons.push("status must be approved");
|
||||
}
|
||||
if (data.risk !== "production-unified-failover-post") {
|
||||
if (!nonEmptyString(data.risk) || data.risk !== "production-unified-failover-post") {
|
||||
reasons.push("risk must be production-unified-failover-post");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,10 @@ const TREND_WINDOW = 5;
|
|||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a full skill health report from metrics data.
|
||||
* Generate a full skill health report aggregating usage and performance metrics.
|
||||
*
|
||||
* @param basePath - Project base path for metrics.json location.
|
||||
* @param staleDays - Staleness threshold in days (default: 60).
|
||||
*/
|
||||
export function generateSkillHealthReport(
|
||||
basePath: string,
|
||||
|
|
@ -110,7 +113,9 @@ export function generateSkillHealthReport(
|
|||
}
|
||||
|
||||
/**
|
||||
* Format a skill health report for terminal display.
|
||||
* Format a skill health report as readable terminal output with warnings.
|
||||
*
|
||||
* @param report - The health report from generateSkillHealthReport.
|
||||
*/
|
||||
export function formatSkillHealthReport(report: SkillHealthReport): string {
|
||||
const lines: string[] = [];
|
||||
|
|
|
|||
|
|
@ -130,8 +130,8 @@ const UNIT_TYPE_SKILL_MANIFEST: Record<string, string[]> = {
|
|||
/**
|
||||
* Resolve the skill allowlist for a unit type.
|
||||
*
|
||||
* @returns Array of normalized skill names when an entry exists, or `null`
|
||||
* when the unit type is unknown (wildcard — caller should not filter).
|
||||
* @param unitType - The unit type identifier (e.g., "execute-task").
|
||||
* @returns Array of normalized skill names when entry exists, or null for wildcard.
|
||||
*/
|
||||
export function resolveSkillManifest(
|
||||
unitType: string | undefined,
|
||||
|
|
@ -143,8 +143,11 @@ export function resolveSkillManifest(
|
|||
}
|
||||
|
||||
/**
|
||||
* Filter a skill list by the manifest for `unitType`. Pass-through when the
|
||||
* manifest is wildcard (unknown unit type) or `unitType` is undefined.
|
||||
* Filter a skill list by the manifest for a unit type. Pass-through when unknown.
|
||||
*
|
||||
* @param skills - Array of skill objects with name property.
|
||||
* @param unitType - The unit type identifier.
|
||||
* @returns Filtered array containing only skills on the allowlist.
|
||||
*/
|
||||
export function filterSkillsByManifest<T extends { name: string }>(
|
||||
skills: T[],
|
||||
|
|
@ -162,6 +165,12 @@ export function filterSkillsByManifest<T extends { name: string }>(
|
|||
*/
|
||||
const warnedMissing = new Set<string>();
|
||||
|
||||
/**
|
||||
* Warn if a unit's manifest references uninstalled skills (dev mode only).
|
||||
*
|
||||
* @param unitType - The unit type identifier.
|
||||
* @param installedNames - Set of currently installed skill names.
|
||||
*/
|
||||
export function warnIfManifestHasMissingSkills(
|
||||
unitType: string | undefined,
|
||||
installedNames: Set<string>,
|
||||
|
|
|
|||
|
|
@ -86,7 +86,9 @@ export function formatDecisionsCompact(decisions: DecisionInput[]): string {
|
|||
// Requirements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compact format for a single requirement record (multi-line). */
|
||||
/**
|
||||
* Format a single requirement in compact multi-line notation.
|
||||
*/
|
||||
export function formatRequirementCompact(req: RequirementInput): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
|
|
@ -98,7 +100,9 @@ export function formatRequirementCompact(req: RequirementInput): string {
|
|||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Format multiple requirements in compact notation. */
|
||||
/**
|
||||
* Format multiple requirements in compact notation.
|
||||
*/
|
||||
export function formatRequirementsCompact(
|
||||
requirements: RequirementInput[],
|
||||
): string {
|
||||
|
|
@ -115,7 +119,9 @@ export function formatRequirementsCompact(
|
|||
// Task Plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compact format for task plan entries. */
|
||||
/**
|
||||
* Format task plan entries in compact notation.
|
||||
*/
|
||||
export function formatTaskPlanCompact(tasks: TaskPlanInput[]): string {
|
||||
if (tasks.length === 0) {
|
||||
return "# Tasks (compact)\n(none)";
|
||||
|
|
@ -144,9 +150,8 @@ export function formatTaskPlanCompact(tasks: TaskPlanInput[]): string {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Measure the token savings of compact format vs markdown format.
|
||||
* Returns savings as a percentage (0-100).
|
||||
* A positive number means compact is smaller (saves tokens).
|
||||
* Measure token savings percentage of compact vs markdown format.
|
||||
* Positive result means compact saves tokens.
|
||||
*/
|
||||
export function measureSavings(
|
||||
compactContent: string,
|
||||
|
|
|
|||
|
|
@ -80,18 +80,31 @@ export function readEvents(logPath: string): WorkflowEvent[] {
|
|||
const content = readFileSync(logPath, "utf-8");
|
||||
const lines = content.split("\n").filter((l) => l.length > 0);
|
||||
const events: WorkflowEvent[] = [];
|
||||
let corruptCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
try {
|
||||
events.push(JSON.parse(line) as WorkflowEvent);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
corruptCount++;
|
||||
const snippet = line.slice(0, 80);
|
||||
logWarning(
|
||||
"event-log",
|
||||
`skipping corrupted event line (${line.length} bytes)`,
|
||||
`skipping corrupted event at ${logPath}:${i + 1} (${line.length} bytes): ${snippet}${line.length > 80 ? "..." : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold check: if more than 5% of lines are corrupt, warn with recovery hint
|
||||
const corruptRatio = lines.length > 0 ? corruptCount / lines.length : 0;
|
||||
if (corruptRatio > 0.05) {
|
||||
logWarning(
|
||||
"event-log",
|
||||
`High corruption rate: ${(corruptRatio * 100).toFixed(1)}% of ${lines.length} lines unreadable. Consider event-log recovery.`,
|
||||
);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import type { Decision } from "./types.js";
|
|||
|
||||
// ─── Manifest Types ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verification evidence row from the database.
|
||||
* Records a command execution, exit code, verdict, and duration.
|
||||
*/
|
||||
export interface VerificationEvidenceRow {
|
||||
id: number;
|
||||
task_id: string;
|
||||
|
|
@ -25,6 +29,10 @@ export interface VerificationEvidenceRow {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete snapshot of workflow state exported from the database.
|
||||
* Includes milestones, slices, tasks, decisions, and verification evidence.
|
||||
*/
|
||||
export interface StateManifest {
|
||||
version: 1;
|
||||
exported_at: string; // ISO 8601
|
||||
|
|
@ -37,6 +45,9 @@ export interface StateManifest {
|
|||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the database adapter or throw if no database is open.
|
||||
*/
|
||||
function requireDb() {
|
||||
const db = _getAdapter();
|
||||
if (!db) throw new Error("workflow-manifest: No database open");
|
||||
|
|
@ -48,6 +59,9 @@ function requireDb() {
|
|||
* null/undefined/non-numeric strings (e.g. "-", "N/A", "").
|
||||
* SQLite can store TEXT in INTEGER columns after migrations or manual inserts.
|
||||
*/
|
||||
/**
|
||||
* Coerce a database value to a number with fallback for nulls and non-numeric text.
|
||||
*/
|
||||
export function toNumeric(
|
||||
value: unknown,
|
||||
fallback: number | null = null,
|
||||
|
|
|
|||
|
|
@ -697,69 +697,86 @@ export function resolveConflict(
|
|||
entityKey: string, // e.g. "task:T01"
|
||||
pick: "main" | "worktree",
|
||||
): void {
|
||||
const conflicts = listConflicts(basePath);
|
||||
const colonIdx = entityKey.indexOf(":");
|
||||
const entityType = entityKey.slice(0, colonIdx);
|
||||
const entityId = entityKey.slice(colonIdx + 1);
|
||||
// Acquire lock BEFORE the first read so no concurrent reconciler or resolver
|
||||
// can interleave between our read-decide-write phases.
|
||||
const lock = acquireSyncLock(basePath);
|
||||
if (!lock.acquired) {
|
||||
logWarning(
|
||||
"reconcile",
|
||||
"resolveConflict: could not acquire sync lock — another operation may be in progress",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = conflicts.findIndex(
|
||||
(c) => c.entityType === entityType && c.entityId === entityId,
|
||||
);
|
||||
if (idx === -1) throw new Error(`No conflict found for entity ${entityKey}`);
|
||||
try {
|
||||
const conflicts = listConflicts(basePath);
|
||||
const colonIdx = entityKey.indexOf(":");
|
||||
const entityType = entityKey.slice(0, colonIdx);
|
||||
const entityId = entityKey.slice(colonIdx + 1);
|
||||
|
||||
const conflict = conflicts[idx]!;
|
||||
const eventsToReplay =
|
||||
pick === "main" ? conflict.mainSideEvents : conflict.worktreeSideEvents;
|
||||
const idx = conflicts.findIndex(
|
||||
(c) => c.entityType === entityType && c.entityId === entityId,
|
||||
);
|
||||
if (idx === -1) throw new Error(`No conflict found for entity ${entityKey}`);
|
||||
|
||||
const mainLogPath = join(basePath, ".sf", "event-log.jsonl");
|
||||
const wtLogPath = join(worktreeBasePath, ".sf", "event-log.jsonl");
|
||||
const mainEvents = readEvents(mainLogPath);
|
||||
const wtEvents = readEvents(wtLogPath);
|
||||
const forkPoint = findForkPoint(mainEvents, wtEvents);
|
||||
const mainBaseEvents = mainEvents.slice(0, forkPoint + 1);
|
||||
const wtBaseEvents = wtEvents.slice(0, forkPoint + 1);
|
||||
const mainDiverged = mainEvents.slice(forkPoint + 1);
|
||||
const wtDiverged = wtEvents.slice(forkPoint + 1);
|
||||
const conflict = conflicts[idx]!;
|
||||
const eventsToReplay =
|
||||
pick === "main" ? conflict.mainSideEvents : conflict.worktreeSideEvents;
|
||||
|
||||
const rewrittenTargetEvents =
|
||||
pick === "main"
|
||||
? rewriteDivergedEventsForEntity(
|
||||
wtDiverged,
|
||||
entityType,
|
||||
entityId,
|
||||
eventsToReplay,
|
||||
)
|
||||
: rewriteDivergedEventsForEntity(
|
||||
mainDiverged,
|
||||
entityType,
|
||||
entityId,
|
||||
eventsToReplay,
|
||||
);
|
||||
const mainLogPath = join(basePath, ".sf", "event-log.jsonl");
|
||||
const wtLogPath = join(worktreeBasePath, ".sf", "event-log.jsonl");
|
||||
const mainEvents = readEvents(mainLogPath);
|
||||
const wtEvents = readEvents(wtLogPath);
|
||||
const forkPoint = findForkPoint(mainEvents, wtEvents);
|
||||
const mainBaseEvents = mainEvents.slice(0, forkPoint + 1);
|
||||
const wtBaseEvents = wtEvents.slice(0, forkPoint + 1);
|
||||
const mainDiverged = mainEvents.slice(forkPoint + 1);
|
||||
const wtDiverged = wtEvents.slice(forkPoint + 1);
|
||||
|
||||
const targetBasePath = pick === "main" ? worktreeBasePath : basePath;
|
||||
const targetBaseEvents = pick === "main" ? wtBaseEvents : mainBaseEvents;
|
||||
writeEventLog(targetBasePath, targetBaseEvents.concat(rewrittenTargetEvents));
|
||||
const rewrittenTargetEvents =
|
||||
pick === "main"
|
||||
? rewriteDivergedEventsForEntity(
|
||||
wtDiverged,
|
||||
entityType,
|
||||
entityId,
|
||||
eventsToReplay,
|
||||
)
|
||||
: rewriteDivergedEventsForEntity(
|
||||
mainDiverged,
|
||||
entityType,
|
||||
entityId,
|
||||
eventsToReplay,
|
||||
);
|
||||
|
||||
// Replay resolved events through the DB (updates DB state)
|
||||
openDatabase(join(basePath, ".sf", "sf.db"));
|
||||
replayEvents(eventsToReplay);
|
||||
invalidateStateCache();
|
||||
clearPathCache();
|
||||
clearParseCache();
|
||||
const targetBasePath = pick === "main" ? worktreeBasePath : basePath;
|
||||
const targetBaseEvents = pick === "main" ? wtBaseEvents : mainBaseEvents;
|
||||
writeEventLog(targetBasePath, targetBaseEvents.concat(rewrittenTargetEvents));
|
||||
|
||||
// Remove resolved conflict from list
|
||||
conflicts.splice(idx, 1);
|
||||
// Replay resolved events through the DB (updates DB state)
|
||||
openDatabase(join(basePath, ".sf", "sf.db"));
|
||||
replayEvents(eventsToReplay);
|
||||
invalidateStateCache();
|
||||
clearPathCache();
|
||||
clearParseCache();
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
// All conflicts resolved — remove CONFLICTS.md and re-run reconciliation
|
||||
// to pick up non-conflicting events that were blocked by D-04 all-or-nothing.
|
||||
removeConflictsFile(basePath);
|
||||
if (worktreeBasePath) {
|
||||
reconcileWorktreeLogs(basePath, worktreeBasePath);
|
||||
// Remove resolved conflict from list
|
||||
conflicts.splice(idx, 1);
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
// All conflicts resolved — remove CONFLICTS.md and re-run reconciliation
|
||||
// to pick up non-conflicting events that were blocked by D-04 all-or-nothing.
|
||||
// Call the inner function directly — we already hold the lock, so we must
|
||||
// not go through reconcileWorktreeLogs (which would attempt a second acquire).
|
||||
removeConflictsFile(basePath);
|
||||
if (worktreeBasePath) {
|
||||
_reconcileWorktreeLogsInner(basePath, worktreeBasePath);
|
||||
}
|
||||
} else {
|
||||
// Re-write CONFLICTS.md with remaining conflicts
|
||||
writeConflictsFile(basePath, conflicts, worktreeBasePath);
|
||||
}
|
||||
} else {
|
||||
// Re-write CONFLICTS.md with remaining conflicts
|
||||
writeConflictsFile(basePath, conflicts, worktreeBasePath);
|
||||
} finally {
|
||||
releaseSyncLock(basePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -565,6 +565,8 @@ export function removeWorktree(
|
|||
const resolvedPathSafe = isInsideWorktreesDir(basePath, resolvedWtPath);
|
||||
|
||||
// If we're inside the worktree, move out first — git can't remove an in-use directory
|
||||
// Note: TOCTOU window between chdir and rmSync — another process could remove the
|
||||
// worktree after we chdir but before we unlink. The fallback/retry pattern handles this.
|
||||
const cwd = process.cwd();
|
||||
const resolvedCwd = existsSync(cwd) ? realpathSync(cwd) : cwd;
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
getMilestoneResquash,
|
||||
resquashMilestoneOnMain,
|
||||
} from "./slice-cadence.js";
|
||||
import { resolveGitDir } from "./worktree-manager.js";
|
||||
import {
|
||||
emitWorktreeCreated,
|
||||
emitWorktreeMerged,
|
||||
|
|
@ -187,6 +188,8 @@ export class WorktreeResolver {
|
|||
}
|
||||
|
||||
const basePath = this.s.originalBasePath || this.s.basePath;
|
||||
// Capture projectRoot before basePath mutation so telemetry uses the original root
|
||||
const projectRoot = basePath;
|
||||
debugLog("WorktreeResolver", {
|
||||
action: "enterMilestone",
|
||||
milestoneId,
|
||||
|
|
@ -212,7 +215,7 @@ export class WorktreeResolver {
|
|||
result: "success",
|
||||
wtPath,
|
||||
});
|
||||
emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
|
||||
emitJournalEvent(projectRoot, {
|
||||
ts: new Date().toISOString(),
|
||||
flowId: randomUUID(),
|
||||
seq: 0,
|
||||
|
|
@ -225,7 +228,7 @@ export class WorktreeResolver {
|
|||
// aggregator can pair it with the eventual worktree-merged event.
|
||||
try {
|
||||
emitWorktreeCreated(
|
||||
this.s.originalBasePath || this.s.basePath,
|
||||
projectRoot,
|
||||
milestoneId,
|
||||
{
|
||||
reason: existingPath ? "enter-milestone" : "create-milestone",
|
||||
|
|
@ -249,7 +252,7 @@ export class WorktreeResolver {
|
|||
result: "error",
|
||||
error: msg,
|
||||
});
|
||||
emitJournalEvent(this.s.originalBasePath || this.s.basePath, {
|
||||
emitJournalEvent(projectRoot, {
|
||||
ts: new Date().toISOString(),
|
||||
flowId: randomUUID(),
|
||||
seq: 0,
|
||||
|
|
@ -612,8 +615,9 @@ export class WorktreeResolver {
|
|||
);
|
||||
|
||||
// Clean up stale merge state left by failed squash-merge (#1389)
|
||||
// Use resolveGitDir to handle worktrees where .git is a file (gitdir pointer)
|
||||
try {
|
||||
const gitDir = join(originalBase || this.s.basePath, ".git");
|
||||
const gitDir = resolveGitDir(originalBase || this.s.basePath);
|
||||
for (const f of ["SQUASH_MSG", "MERGE_HEAD", "MERGE_MSG"]) {
|
||||
const p = join(gitDir, f);
|
||||
if (existsSync(p)) unlinkSync(p);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue