Skip to content

fix(adapters): project native skills and harden spawn state#95

Open
twaldin wants to merge 1 commit intomainfrom
fix/native-skills-spawn-race-version
Open

fix(adapters): project native skills and harden spawn state#95
twaldin wants to merge 1 commit intomainfrom
fix/native-skills-spawn-race-version

Conversation

@twaldin
Copy link
Copy Markdown
Owner

@twaldin twaldin commented May 8, 2026

Summary

  • project droid skills into Factory's native .factory/skills tree and pi skills into .pi/skills
  • restore skill references in generated flt instruction templates and cleanup native skill projections
  • register agents immediately after tmux session creation, reconcile controller state before spawn/list/kill, and surface TUI spawn-state mismatches
  • bump package version to 0.3.6 to close Release tag v0.3.6 still reports package version 0.3.3 #94

Validation

  • bun src/cli.ts --version
  • bun test tests/unit/skills.test.ts tests/unit/instructions.test.ts tests/unit/spawn.test.ts tests/unit/spawn-bootstrap-uses-deliver.test.ts
  • bun test tests/unit/adapters/pi.test.ts
  • bun test tests/adapters/factory-droid-telemetry.test.ts tests/adapters/pi-telemetry.test.ts
  • bunx tsc --noEmit

Closes #94

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for Droid and PI CLI adapters with skill management
    • Introduced 'spawning' agent status for improved state tracking
  • Bug Fixes

    • Improved spawn operation resilience with better error detection and automatic cleanup
    • Enhanced agent state reconciliation during controller operations
    • Better session state consistency during fast restarts
  • Tests

    • Added test coverage for new adapter skill configurations

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds native skill directory support for Droid and PI CLI adapters, implements spawn state lifecycle management with failure cleanup, and ensures agent state consistency through pre-RPC reconciliation. Package version is bumped to 0.3.6 to match the release tag.

Changes

Native Skills and Spawn Lifecycle

Layer / File(s) Summary
Type Contracts
src/adapters/types.ts
AgentStatus gains 'spawning' state; CliAdapter gains optional skillDir?: string field.
Adapter Configuration
src/adapters/droid.ts, src/adapters/pi.ts
Droid and PI adapters now specify native skill directories: .factory/skills and .pi/skills respectively.
Skill Directory Routing
src/instructions.ts, src/skills.ts
skillsDir(cli) routes droid/pi to native paths; projectSkills prioritizes adapter.skillDir for installation.
Spawn Lifecycle Management
src/commands/spawn.ts
Adds spawn state tracking ('spawning' status), session collision detection, and cleanupFailedSpawn helper for teardown on failure. Readiness logic updated to use ANSI-stripped pane comparisons.
Controller Reconciliation
src/controller/server.ts
Exports reconcileAgents() and calls it before spawn, kill, and list RPC actions. Enhanced CLI/model detection with regex matching for droid and pi.
TUI Feedback
src/tui/app.ts
Spawn command now shows conditional success/failure banner based on agent state presence after polling.
Template Skill Injection
templates/system-block-root.md, templates/system-block-subagent.md, templates/workflow-block.md
Each template now includes {{skills}} placeholder for skill content injection.
Test Coverage
tests/unit/instructions.test.ts, tests/unit/skills.test.ts, tests/unit/spawn.test.ts, tests/unit/spawn-bootstrap-uses-deliver.test.ts
New tests verify CLI-specific skill paths, adapter configurations, spawn state lifecycle, session collision handling, and tmux mocking infrastructure.
Version Release
package.json
Version bumped to 0.3.6 to match release tag.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • twaldin/flt#37: Modifies spawn/waitForReady behavior with ANSI-stripped pane comparison logic for readiness fallthrough.
  • twaldin/flt#42: Reworks reconcileAgents function export and agent-detection logic.
  • twaldin/flt#50: Extends spawnDirect function in src/commands/spawn.ts with git-hooks handling alongside spawn state changes.

Poem

🐰 Adapters bloom with native skills so fine,
Droid finds factories, PI draws its line.
Spawning states dance through cleanup's care,
Version bumped to 0.3.6 everywhere!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(adapters): project native skills and harden spawn state' accurately reflects the main changes: adding skillDir configuration to adapters and improving spawn state handling.
Linked Issues check ✅ Passed The PR fully addresses issue #94 by bumping package.json from 0.3.3 to 0.3.6, ensuring flt --version reports the correct release version.
Out of Scope Changes check ✅ Passed All changes are directly related to the objectives: version bump for #94 closure, adapter skillDir configuration, spawn state improvements, skill projection, and template updates.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/native-skills-spawn-race-version

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/unit/spawn.test.ts (1)

112-139: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Two existing tests assert the spawning state (call index [0]) instead of the final state (call index [1])

After this PR, setAgent is called twice per spawn:

  • calls[0] — initial registration with status: 'spawning'
  • calls[1] — final update with status: undefined

The first test (uses matching preset...) was correctly updated to use calls[1] (line 105). The other two still reference calls[0]. Tests pass because model is the same in both calls, but any future assertion on status or statusAt against these indices will silently target the wrong state.

🛠️ Proposed fix
 // "uses explicit --preset over name when both match"
-    const [, agentState] = mockSetAgent.mock.calls[0] as [string, Record<string, unknown>]
+    const [, agentState] = mockSetAgent.mock.calls[1] as [string, Record<string, unknown>]
     expect(agentState.model).toBe('sonnet')

 // "uses default behavior when no preset matches name"
-    const [, agentState] = mockSetAgent.mock.calls[0] as [string, Record<string, unknown>]
+    const [, agentState] = mockSetAgent.mock.calls[1] as [string, Record<string, unknown>]
     expect(agentState.model).toBe('haiku')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/spawn.test.ts` around lines 112 - 139, The tests 'uses default
behavior when no preset matches name' and the other remaining test still assert
the agent final state using mockSetAgent.mock.calls[0]; update them to assert
the final agent state using mockSetAgent.mock.calls[1] (the second call) so they
inspect the post-spawn update rather than the initial spawning registration;
locate the assertions that extract [, agentState] from mockSetAgent.mock.calls
and change the index to 1 for those tests (the same pattern used in the
already-updated 'uses matching preset...' test) to ensure future assertions on
status/statusAt target the final state.
🧹 Nitpick comments (1)
src/skills.ts (1)

303-316: ⚡ Quick win

cleanupSkills should gate instruction-file cleanup on !adapter.skillDir to match projectSkills routing

projectSkills now routes exclusively based on adapter.skillDir/adapter-name, but cleanupSkills still uses only hardcoded adapter-name checks. Adapters with skillDir set (droid, pi) currently trigger the instruction-file cleanup path—they just find no markers and return early. As new adapters with skillDir are added, the intent is obscured and the file read is wasted.

♻️ Proposed fix
-  if (cliName !== 'claude-code' && cliName !== 'opencode' && adapter.instructionFile) {
+  if (!adapter.skillDir && cliName !== 'claude-code' && cliName !== 'opencode' && adapter.instructionFile) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/skills.ts` around lines 303 - 316, The cleanup logic currently runs for
adapters based only on adapter.name and adapter.instructionFile; change it to
also require that adapter.skillDir is falsy (i.e., only run when
!adapter.skillDir) so it matches projectSkills' routing; locate the block using
cliName/adapter.instructionFile and SKILLS_MARKER_START/SKILLS_MARKER_END and
wrap or extend the condition (cliName !== 'claude-code' && cliName !==
'opencode' && adapter.instructionFile) to also check !adapter.skillDir before
reading/writing the instruction file and performing the regex replace.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tui/app.ts`:
- Around line 1030-1037: This block assumes the controller has flushed agent
state to disk before returning so poll() (which calls allAgents()) will see the
new agent; add a concise clarifying comment above this .then(...) explaining the
implicit ordering dependency: that poll() reads state.json synchronously via
allAgents(), that reconcileAgents() (in the controller) must finish
writing/flush state before the RPC response, and that if that invariant is
relaxed the "no agent state" branch may fire spuriously; reference the
functions/fields poll(), allAgents(), reconcileAgents(), this.state.agents, and
setBanner in the comment so future maintainers know the race condition and where
to fix it.

---

Outside diff comments:
In `@tests/unit/spawn.test.ts`:
- Around line 112-139: The tests 'uses default behavior when no preset matches
name' and the other remaining test still assert the agent final state using
mockSetAgent.mock.calls[0]; update them to assert the final agent state using
mockSetAgent.mock.calls[1] (the second call) so they inspect the post-spawn
update rather than the initial spawning registration; locate the assertions that
extract [, agentState] from mockSetAgent.mock.calls and change the index to 1
for those tests (the same pattern used in the already-updated 'uses matching
preset...' test) to ensure future assertions on status/statusAt target the final
state.

---

Nitpick comments:
In `@src/skills.ts`:
- Around line 303-316: The cleanup logic currently runs for adapters based only
on adapter.name and adapter.instructionFile; change it to also require that
adapter.skillDir is falsy (i.e., only run when !adapter.skillDir) so it matches
projectSkills' routing; locate the block using cliName/adapter.instructionFile
and SKILLS_MARKER_START/SKILLS_MARKER_END and wrap or extend the condition
(cliName !== 'claude-code' && cliName !== 'opencode' && adapter.instructionFile)
to also check !adapter.skillDir before reading/writing the instruction file and
performing the regex replace.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: df0ed9ec-c46d-41a7-a8a5-ff84772673d3

📥 Commits

Reviewing files that changed from the base of the PR and between fab4b49 and e46f99c.

📒 Files selected for processing (16)
  • package.json
  • src/adapters/droid.ts
  • src/adapters/pi.ts
  • src/adapters/types.ts
  • src/commands/spawn.ts
  • src/controller/server.ts
  • src/instructions.ts
  • src/skills.ts
  • src/tui/app.ts
  • templates/system-block-root.md
  • templates/system-block-subagent.md
  • templates/workflow-block.md
  • tests/unit/instructions.test.ts
  • tests/unit/skills.test.ts
  • tests/unit/spawn-bootstrap-uses-deliver.test.ts
  • tests/unit/spawn.test.ts

Comment thread src/tui/app.ts
Comment on lines 1030 to +1037
.then(() => {
clearTimeout(staleTimer)
this.setBanner(`Spawned ${name}`, 'green', 3000)
this.poll()
if (this.state.agents.some(agent => agent.name === name)) {
this.setBanner(`Spawned ${name}`, 'green', 3000)
} else {
this.setBanner(`Spawn ${name} returned but no agent state was found`, 'red', 8000)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Implicit ordering assumption between controller state flush and TUI poll() read.

The "no agent state" banner fires when this.state.agents doesn't yet contain name after this.poll(). poll() reads state.json synchronously via allAgents(); if the controller writes agent state asynchronously or the OS has not yet made the write visible at the time allAgents() runs, the read can race with the write and the error banner fires spuriously even though spawn succeeded.

The AI summary states the controller calls reconcileAgents() before returning the RPC response, so this ordering is currently enforced by design. If that invariant is ever relaxed (e.g., fire-and-forget reconcile), this check will produce false negatives silently. Consider adding a comment here documenting the dependency:

📝 Suggested clarifying comment
     spawn({ ... })
       .then(() => {
         clearTimeout(staleTimer)
+        // poll() reads state.json synchronously; safe because the controller
+        // calls reconcileAgents() before resolving the spawn RPC, so the new
+        // agent record is guaranteed to be on disk by this point.
         this.poll()
         if (this.state.agents.some(agent => agent.name === name)) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.then(() => {
clearTimeout(staleTimer)
this.setBanner(`Spawned ${name}`, 'green', 3000)
this.poll()
if (this.state.agents.some(agent => agent.name === name)) {
this.setBanner(`Spawned ${name}`, 'green', 3000)
} else {
this.setBanner(`Spawn ${name} returned but no agent state was found`, 'red', 8000)
}
.then(() => {
clearTimeout(staleTimer)
// poll() reads state.json synchronously; safe because the controller
// calls reconcileAgents() before resolving the spawn RPC, so the new
// agent record is guaranteed to be on disk by this point.
this.poll()
if (this.state.agents.some(agent => agent.name === name)) {
this.setBanner(`Spawned ${name}`, 'green', 3000)
} else {
this.setBanner(`Spawn ${name} returned but no agent state was found`, 'red', 8000)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tui/app.ts` around lines 1030 - 1037, This block assumes the controller
has flushed agent state to disk before returning so poll() (which calls
allAgents()) will see the new agent; add a concise clarifying comment above this
.then(...) explaining the implicit ordering dependency: that poll() reads
state.json synchronously via allAgents(), that reconcileAgents() (in the
controller) must finish writing/flush state before the RPC response, and that if
that invariant is relaxed the "no agent state" branch may fire spuriously;
reference the functions/fields poll(), allAgents(), reconcileAgents(),
this.state.agents, and setBanner in the comment so future maintainers know the
race condition and where to fix it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Release tag v0.3.6 still reports package version 0.3.3

1 participant