Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9c88375
docs(specs): analyze codebase and define spec for feature dependency …
arielshad Apr 5, 2026
fa1298f
docs(specs): define requirements and product questions for feature de…
arielshad Apr 5, 2026
55ec642
docs(specs): research technical approach for feature dependency rebase
arielshad Apr 5, 2026
84aa6c7
docs(specs): create implementation plan and tasks for feature depende…
arielshad Apr 5, 2026
d0febff
feat(domain): add reparent feature use case with validation and lifec…
arielshad Apr 5, 2026
912ee0f
feat(web): add reparent-feature server action and di registration
arielshad Apr 5, 2026
859c0af
feat(domain): add rebase-on-branch to git pr service
arielshad Apr 5, 2026
5b442d3
feat(domain): inject rebase dependencies into check-and-unblock-featu…
arielshad Apr 5, 2026
a19c4f3
feat(domain): add auto-rebase orchestration to check-and-unblock-feat…
arielshad Apr 5, 2026
5ab0d6a
feat(web): add canvas drag-to-connect reparenting and edge deletion
arielshad Apr 5, 2026
be6f7e6
chore(specs): capture evidence for feature dependency rebase
arielshad Apr 5, 2026
c529364
chore(specs): refresh evidence for feature dependency rebase
arielshad Apr 7, 2026
b874e34
chore(specs): refresh unit test evidence for feature dependency rebase
arielshad Apr 7, 2026
264d3d9
chore(specs): refresh evidence with fresh app screenshots and test runs
arielshad Apr 7, 2026
5e03ac2
chore(specs): mark evidence phase complete for feature dependency rebase
arielshad Apr 7, 2026
7c2b9ec
fix(web): add storybook mock for reparent-feature server action
arielshad Apr 7, 2026
2d93502
chore(specs): update last-updated timestamp for feature dependency re…
arielshad Apr 7, 2026
463ef6b
fix(ci): attempt 1/10 — add missing injectskills field and rebaseonbr…
arielshad Apr 7, 2026
68a28ff
fix(ci): attempt 2/10 — shorten two docs commit subjects to satisfy 7…
arielshad Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .storybook/mocks/app/actions/reparent-feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export async function reparentFeature(
_featureId: string,
_parentId: string | null
): Promise<{ success: boolean; error?: string }> {
return { success: true };
}
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,22 @@ export interface IGitPrService {
*/
rebaseOnMain(cwd: string, featureBranch: string, baseBranch: string): Promise<void>;

/**
* Rebase the feature branch onto `origin/<targetBranch>`.
* Fetches the target branch from the remote first to ensure the
* remote-tracking ref is up-to-date, then rebases the feature branch
* onto it. Similar to {@link rebaseOnMain} but targets an arbitrary
* branch instead of the repository's default branch.
*
* @param cwd - Working directory path
* @param featureBranch - The feature branch to rebase
* @param targetBranch - The target branch name (rebase target will be origin/<targetBranch>)
* @throws GitPrError with GIT_ERROR code if the worktree is dirty
* @throws GitPrError with REBASE_CONFLICT code if conflicts are detected
* @throws GitPrError with BRANCH_NOT_FOUND code if a branch does not exist
*/
rebaseOnBranch(cwd: string, featureBranch: string, targetBranch: string): Promise<void>;

/**
* Get the list of files with unresolved conflicts (unmerged paths).
* Uses `git diff --name-only --diff-filter=U` to identify conflicted files.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,59 @@
* CheckAndUnblockFeaturesUseCase
*
* Evaluates whether blocked direct children of a parent feature are now
* eligible to start, and if so transitions them to Started and spawns their
* agents.
* eligible to start, and if so transitions them to Started, rebases their
* branches onto the parent branch, and spawns their agents.
*
* Business Rules:
* - Only direct children of parentFeatureId are evaluated (no recursive traversal).
* Grandchildren stay Blocked until their own direct parent progresses.
* - Gate: parent lifecycle must be in POST_IMPLEMENTATION (Implementation, Review, Maintain).
* - Idempotent: already-Started children are not touched; calling execute() twice is safe.
* - spawn() is skipped for children missing agentRunId or specPath (defensive guard).
* - Auto-rebase: each blocked child's branch is rebased onto the parent branch
* before spawning the agent. Rebase failures are isolated per-child and recorded
* in the activity timeline. Agent spawns regardless of rebase outcome.
* - NFR-3: rebase is skipped if the child has an active (running) agent run.
*
* Called from: UpdateFeatureLifecycleUseCase after every lifecycle transition.
*/

import { injectable, inject } from 'tsyringe';
import { SdlcLifecycle } from '../../../domain/generated/output.js';
import { randomUUID } from 'node:crypto';
import { SdlcLifecycle, AgentRunStatus, AgentType } from '../../../domain/generated/output.js';
import type { Feature } from '../../../domain/generated/output.js';
import type { IFeatureRepository } from '../../ports/output/repositories/feature-repository.interface.js';
import type { IFeatureAgentProcessService } from '../../ports/output/agents/feature-agent-process.interface.js';
import type { IGitPrService } from '../../ports/output/services/git-pr-service.interface.js';
import {
GitPrError,
GitPrErrorCode,
} from '../../ports/output/services/git-pr-service.interface.js';
import type { IWorktreeService } from '../../ports/output/services/worktree-service.interface.js';
import type { ConflictResolutionService } from '../../../infrastructure/services/agents/conflict-resolution/conflict-resolution.service.js';
import type { IAgentRunRepository } from '../../ports/output/agents/agent-run-repository.interface.js';
import type { IPhaseTimingRepository } from '../../ports/output/agents/phase-timing-repository.interface.js';
import { POST_IMPLEMENTATION } from '../../../domain/lifecycle-gates.js';

/** Maximum time (ms) to wait for a single child rebase before aborting. */
const REBASE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

@injectable()
export class CheckAndUnblockFeaturesUseCase {
constructor(
@inject('IFeatureRepository') private readonly featureRepo: IFeatureRepository,
@inject('IFeatureAgentProcessService')
private readonly agentProcess: IFeatureAgentProcessService
private readonly agentProcess: IFeatureAgentProcessService,
@inject('IGitPrService')
private readonly gitPrService: IGitPrService,
@inject('IWorktreeService')
private readonly worktreeService: IWorktreeService,
@inject('ConflictResolutionService')
private readonly conflictResolutionService: ConflictResolutionService,
@inject('IAgentRunRepository')
private readonly agentRunRepo: IAgentRunRepository,
@inject('IPhaseTimingRepository')
private readonly phaseTimingRepo: IPhaseTimingRepository
) {}

/**
Expand Down Expand Up @@ -55,6 +83,9 @@ export class CheckAndUnblockFeaturesUseCase {
child.updatedAt = new Date();
await this.featureRepo.update(child);

// Rebase child branch onto parent branch (isolated per-child)
await this.rebaseChildOntoParent(child, parent);

// Spawn agent using fields set at feature creation time
if (child.agentRunId && child.specPath) {
this.agentProcess.spawn(
Expand All @@ -78,4 +109,155 @@ export class CheckAndUnblockFeaturesUseCase {
}
}
}

/**
* Rebase a child feature branch onto the parent feature branch.
*
* Creates an agent run + phase timing for activity timeline tracking.
* Stashes uncommitted changes before rebase and restores in finally block.
* Delegates to ConflictResolutionService on conflicts.
* Failures are recorded but do not prevent agent spawn.
*
* Skips rebase if the child has an active (running) agent run (NFR-3).
*/
private async rebaseChildOntoParent(child: Feature, parent: Feature): Promise<void> {
// NFR-3: skip rebase if child has an active agent run
if (child.agentRunId) {
const existingRun = await this.agentRunRepo.findById(child.agentRunId);
if (existingRun && existingRun.status === AgentRunStatus.running) {
return;
}
}

// Create standalone agent run + phase timing for activity timeline
const now = new Date().toISOString();
const agentRunId = randomUUID();
const phaseTimingId = randomUUID();

await this.agentRunRepo.create({
id: agentRunId,
agentType: AgentType.ClaudeCode,
agentName: 'rebase',
status: AgentRunStatus.running,
prompt: `Rebase ${child.branch} onto parent ${parent.branch}`,
threadId: agentRunId,
startedAt: now,
featureId: child.id,
repositoryPath: child.repositoryPath,
createdAt: now,
updatedAt: now,
});

await this.phaseTimingRepo.save({
id: phaseTimingId,
agentRunId,
phase: 'rebase-on-parent',
startedAt: now,
createdAt: now,
updatedAt: now,
});

const startMs = Date.now();

try {
// Resolve CWD — worktree path if it exists, else repo root
const cwd = await this.resolveCwd(child.repositoryPath, child.branch);

// Stash uncommitted changes (smart rebase)
const didStash = await this.gitPrService.stash(
cwd,
'shep-rebase: auto-stash before parent rebase'
);

try {
// Rebase child branch onto parent branch (with timeout)
await Promise.race([
this.performRebase(cwd, child.branch, parent.branch),
this.createTimeout(REBASE_TIMEOUT_MS, child.branch),
]);
} finally {
// Restore stashed changes (regardless of rebase outcome)
if (didStash) {
await this.gitPrService.stashPop(cwd);
}
}

// Rebase succeeded
await this.completeTiming(agentRunId, phaseTimingId, startMs, 'success');
} catch (error) {
// Record failure in activity timeline but do not throw —
// agent spawn proceeds regardless of rebase outcome
const message = error instanceof Error ? error.message : String(error);
await this.completeTiming(agentRunId, phaseTimingId, startMs, 'error', message);
}
}

/**
* Perform the rebase with conflict resolution.
*/
private async performRebase(
cwd: string,
childBranch: string,
parentBranch: string
): Promise<void> {
try {
await this.gitPrService.rebaseOnBranch(cwd, childBranch, parentBranch);
} catch (error) {
if (error instanceof GitPrError && error.code === GitPrErrorCode.REBASE_CONFLICT) {
// Delegate to agent-powered conflict resolution
await this.conflictResolutionService.resolve(cwd, childBranch, parentBranch);
} else {
throw error;
}
}
}

/**
* Create a timeout promise that rejects after the specified duration.
*/
private createTimeout(ms: number, childBranch: string): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(`Rebase timeout: ${childBranch} exceeded ${ms}ms`)), ms);
});
}

/**
* Complete the phase timing and update agent run status.
*/
private async completeTiming(
agentRunId: string,
phaseTimingId: string,
startMs: number,
exitCode: 'success' | 'error',
errorMessage?: string
): Promise<void> {
const completedAt = new Date().toISOString();
const durationMs = Date.now() - startMs;

await this.phaseTimingRepo.update(phaseTimingId, {
completedAt,
durationMs: BigInt(durationMs),
exitCode,
...(errorMessage && { errorMessage }),
});

await this.agentRunRepo.updateStatus(
agentRunId,
exitCode === 'success' ? AgentRunStatus.completed : AgentRunStatus.failed,
{ completedAt, ...(errorMessage && { error: errorMessage }) }
);
}

/**
* Resolve the correct working directory for the child feature.
* Uses the worktree path if a worktree exists for this branch,
* otherwise falls back to the repository root.
*/
private async resolveCwd(repositoryPath: string, branch: string): Promise<string> {
const hasWorktree = await this.worktreeService.exists(repositoryPath, branch);
if (hasWorktree) {
return this.worktreeService.getWorktreePath(repositoryPath, branch);
}
return repositoryPath;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* ReparentFeatureUseCase
*
* Updates a feature's parent dependency (or clears it). Performs validation:
* - Same-repository constraint (child and parent must share repositoryPath)
* - Cycle detection via upward ancestor walk
* - Lifecycle guards (cannot reparent completed/archived/deleting features)
* - Lifecycle state adjustment based on new parent's lifecycle
*
* After reparenting, if the new parent is post-implementation, calls
* CheckAndUnblockFeaturesUseCase to trigger the unblock+rebase flow
* for any Blocked children of the reparented feature.
*/

import { injectable, inject } from 'tsyringe';
import { SdlcLifecycle } from '../../../domain/generated/output.js';
import type { IFeatureRepository } from '../../ports/output/repositories/feature-repository.interface.js';
import { POST_IMPLEMENTATION } from '../../../domain/lifecycle-gates.js';
import { CheckAndUnblockFeaturesUseCase } from './check-and-unblock-features.use-case.js';

/** Lifecycle states that cannot be reparented. */
const NON_REPARENTABLE_STATES = new Set<SdlcLifecycle>([
SdlcLifecycle.Maintain,
SdlcLifecycle.Archived,
SdlcLifecycle.Deleting,
]);

export interface ReparentFeatureInput {
featureId: string;
parentId: string | null;
}

@injectable()
export class ReparentFeatureUseCase {
constructor(
@inject('IFeatureRepository')
private readonly featureRepo: IFeatureRepository,
@inject(CheckAndUnblockFeaturesUseCase)
private readonly checkAndUnblock: CheckAndUnblockFeaturesUseCase
) {}

async execute(input: ReparentFeatureInput): Promise<void> {
const { featureId, parentId } = input;

// Self-reparent guard
if (parentId !== null && featureId === parentId) {
throw new Error('A feature cannot be set as parent of itself.');
}

// Load child feature
const child = await this.featureRepo.findById(featureId);
if (!child) {
throw new Error(`Feature not found: ${featureId}`);
}

// Lifecycle guard — reject completed/terminal features
if (NON_REPARENTABLE_STATES.has(child.lifecycle)) {
throw new Error(
`Cannot reparent feature "${child.name}": lifecycle is ${child.lifecycle}. ` +
'Only active features can be reparented.'
);
}

// Unparent case
if (parentId === null) {
const newLifecycle =
child.lifecycle === SdlcLifecycle.Blocked ? SdlcLifecycle.Started : child.lifecycle;
await this.featureRepo.update({
...child,
parentId: undefined,
lifecycle: newLifecycle,
updatedAt: new Date(),
});
return;
}

// Load parent feature
const parent = await this.featureRepo.findById(parentId);
if (!parent) {
throw new Error(`Parent feature not found: ${parentId}`);
}

// Same-repository constraint
if (child.repositoryPath !== parent.repositoryPath) {
throw new Error('Features must be in the same repository to form a dependency.');
}

// Cycle detection — walk from proposed parent upward
await this.detectCycle(featureId, parentId);

// Determine lifecycle adjustment based on new parent's lifecycle
let newLifecycle = child.lifecycle;
if (parent.lifecycle === SdlcLifecycle.Blocked || !POST_IMPLEMENTATION.has(parent.lifecycle)) {
// Parent is pre-implementation or Blocked — child should be Blocked
if (child.lifecycle !== SdlcLifecycle.Blocked && child.lifecycle !== SdlcLifecycle.Pending) {
newLifecycle = SdlcLifecycle.Blocked;
}
}

// Persist the reparent
await this.featureRepo.update({
...child,
parentId,
lifecycle: newLifecycle,
updatedAt: new Date(),
});

// If new parent is post-implementation, trigger unblock flow for the
// reparented feature's own children (the feature itself may now be a parent
// of Blocked children that should be unblocked)
if (POST_IMPLEMENTATION.has(parent.lifecycle)) {
await this.checkAndUnblock.execute(featureId);
}
}

/**
* Walk the ancestor chain from the proposed parent upward.
* If the child feature ID is found in the chain, a cycle exists.
*/
private async detectCycle(childId: string, parentId: string): Promise<void> {
const visited = new Set<string>([childId]);
let cursor: string | undefined = parentId;

while (cursor) {
if (visited.has(cursor)) {
throw new Error(
`Cycle detected in feature dependency chain. ` +
`Setting ${parentId} as parent of ${childId} would create a circular dependency.`
);
}
visited.add(cursor);
const ancestor = await this.featureRepo.findById(cursor);
cursor = ancestor?.parentId ?? undefined;
}
}
}
Loading
Loading