Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,7 @@ describe('factory-feature-guardian runtime paths', () => {

const catalog = await loadFactoryGuardianCatalog(production.ctx);

expect(catalog.features).toHaveLength(310);
expect(catalog.features).toHaveLength(314);
expect(catalog.features.flatMap((feature) => feature.locations)).toEqual(
expect.arrayContaining(['src/environments/', 'src/triage/', 'src/fleet/', 'src/writeback/'])
);
Expand Down
38 changes: 33 additions & 5 deletions .agentworkforce/features/manifest.yaml
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
version: '1.1'
updated: '2026-08-05'
updated: '2026-08-07'
catalog:
category_count: 25
feature_count: 310
feature_count: 314
tier_counts:
1: 48
2: 124
2: 125
3: 11
4: 52
5: 60
4: 53
5: 62
6: 15

# Every user-facing feature in @agent-relay/factory, categorized and scored.
Expand Down Expand Up @@ -821,6 +821,27 @@ categories:
location: src/orchestrator/factory.ts, src/triage/heuristic.ts, src/dispatch/templates.ts
verify_tier: 6

- id: pr-routed-babysitter-discovery
name: Routed Repository PR Babysitter Discovery
api: babysitter.mode=routed-open-prs
description: Discover open non-draft same-repository PRs only from repos.names, fall back to PR title and body, and account for every excluded, deferred, unreadable, or failed candidate
location: src/github/routed-pr-babysitter.ts, src/orchestrator/factory.ts
verify_tier: 5

- id: pr-routed-babysitter-ownership
name: Routed PR Work-Unit Ownership
api: StateStore.claimRoutedPrBabysitter()
description: Atomically claim exact normalized repo and PR work units across routed and issue-created intake before spawn, with durable restart recovery and bounded admission
location: src/ports/state.ts, src/state/in-memory-state-store.ts, src/state/file-state-store.ts, src/orchestrator/factory.ts
verify_tier: 4

- id: pr-routed-babysitter-opt-out
name: Routed PR Pre-Write Opt-Out
api: babysitter.excludeLabels / babysitter.excludePullRequests
description: Reject configured PR identities or labeled PRs before spawn and require automatic workers to re-check labels before their first provider write
location: src/config/schema.ts, src/github/routed-pr-babysitter.ts, src/dispatch/templates.ts
verify_tier: 5

- id: pr-standalone-babysitter-validation
name: Standalone Babysitter PR Validation
api: readStandalonePullRequest()
Expand Down Expand Up @@ -2142,6 +2163,13 @@ categories:
location: src/config/schema.ts, src/orchestrator/factory.ts
verify_tier: 2

- id: config-babysitter-routed-intake
name: Routed Babysitter Intake Controls
api: factory.config.json#babysitter.mode
description: Keep factory-created intake as the default while explicitly configuring routed discovery, exclusions, and notifications-off behavior
location: src/config/schema.ts, src/orchestrator/factory.ts
verify_tier: 2

- id: config-merge-policy
name: Merge Policy
api: factory.config.json#mergePolicy
Expand Down
13 changes: 13 additions & 0 deletions .agentworkforce/features/verify/procedures.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,19 @@ Verify the PR contains the issue key, commits, tests, and review activity; no dr

Enable `babysitter.enabled`, open a non-draft PR through the issue-driven path, and confirm only one babysitter starts. Make CI fail or add a review request, confirm only the exact repo/PR owner wakes, then enter the documented destructive critical section and prove Factory ACKs only after the no-submit fence persists. Confirm coalesced activity is delivered once after exit, the babysitter fixes the current PR branch, then send/observe its readiness signal. A mismatched signal or draft/closed PR must not advance the issue.

For widened intake, additionally set `babysitter.mode` to
`routed-open-prs` in a disposable workspace. Provide one eligible PR, one PR
with `factory:skip-babysitter`, one configured `excludePullRequests` identity,
one draft, and one same-number PR in a repository absent from `repos.names`.
Run a dry/read-only sweep and verify the exact discovery counters account for
every candidate without spawning or writing. Then use a fake fleet/state test
to prove the eligible PR is atomically claimed before spawn, two owners cannot
claim it, the opt-outs spawn nothing, only one new routed PR is admitted per
sweep, capacity deferrals are logged, restart restores the exact repo/PR owner,
and the automatic prompt disables notifications and checks labels before its
first provider write. Do not run a provider-writing widened sweep until the
GitHub App identity path is independently validated.

Do not attempt a cross-host active/active control-plane test: the supported ownership topology is multiple Factory processes sharing one same-host `FileStateStore`. Remote relay execution nodes are supported and do not need that directory.

### Standalone babysitter
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,46 @@ existing PR branch, and always leaves the final review and merge to a human.
The command prints a spawn receipt and returns; the PR-keyed task-exit worker
continues on the relay broker and reports completion or access blockers there.

### Automatic routed-PR babysitting

`babysitter.enabled` continues to cover Factory-created PRs by default. To
admit existing PRs independently of issue dispatch, set:

```json
{
"babysitter": {
"enabled": true,
"mode": "routed-open-prs",
"excludeLabels": ["factory:skip-babysitter"],
"excludePullRequests": [],
"notifyHumans": false
}
}
```

The widened mode scans only repositories named by `repos.names`; other routing
fallbacks do not silently expand the sweep. It admits open, non-draft,
same-repository PRs and uses their title/body as the standalone definition of
done when no Factory issue lifecycle exists. Such PRs never gain an issue state
transition or automatic merge. The exact normalized `owner/repo#number` work
unit is claimed durably before spawn, so issue-driven and routed discovery
cannot start two babysitters for one PR.

Opt-out labels and `excludePullRequests` are checked before spawn. Automatic
workers re-check the labels before their first and every later provider write. Human-facing
status comments, mentions, and escalation stay disabled unless
`notifyHumans` is explicitly enabled. Admission uses `batchSize` as its active
ceiling and starts no more than one new routed PR per sweep. Every sweep logs
exact scanned, eligible, excluded, incomplete, already-owned, admitted,
capacity-deferred, unchanged, and failed counts; deferred work is retried, not
silently truncated. This bounded intake is deliberate: issue #222 recorded a
GitHub API measurement of 302 open PRs, 248 ready for review, across 41 routed
repositories on 2026-08-07.

Do not enable `routed-open-prs` until the GitHub App identity path from #221 is
deployed and validated. The code can land first because the default mode remains
`factory-created`.

### Scheduled sync-fidelity canary

`factory canary` is the regression detector for upstream sync drift: if a synced
Expand Down
5 changes: 4 additions & 1 deletion src/cli/fleet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,10 @@ async function runStandaloneBabysitCommand(
crossRepository: pr.crossRepository,
maintainerCanModify: pr.maintainerCanModify,
},
standaloneBabysitter: { specSource },
// The explicit CLI command is itself the human opt-in and preserves its
// existing conversational behavior. Automatic routed sweeps pass the
// safer notification flag from config instead.
standaloneBabysitter: { specSource, notifyHumans: true },
Comment thread
khaliqgant marked this conversation as resolved.
integrationsMountRoot: resolveIntegrationsMountRoot(mount),
testGuidance,
})
Expand Down
37 changes: 36 additions & 1 deletion src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ describe('FactoryConfigSchema', () => {
reviewer: 'spawn:claude',
babysitter: 'spawn:claude',
})
expect(parsed.babysitter).toEqual({ enabled: false })
expect(parsed.babysitter).toEqual({
enabled: false,
mode: 'factory-created',
excludeLabels: ['factory:skip-babysitter'],
excludePullRequests: [],
notifyHumans: false,
})
expect(parsed.terminalState).toBe('human-review')
expect(parsed.stateIds.humanReview).toBeUndefined()
expect(parsed.loop.registryPath).toBe('/tmp/factory-run/factory-loop-registry.json')
Expand Down Expand Up @@ -314,6 +320,35 @@ describe('FactoryConfigSchema', () => {
expect(parsed.repos.names).toEqual(['pear', 'cloud', 'agentswarm'])
})

it('requires explicit routed repositories for routed PR babysitting', () => {
expect(() => FactoryConfigSchema.parse({
babysitter: { enabled: true, mode: 'routed-open-prs' },
repos: {},
})).toThrow(/repos\.names must contain at least one repository/u)

expect(FactoryConfigSchema.parse({
babysitter: { enabled: true, mode: 'factory-created' },
repos: {},
}).babysitter.mode).toBe('factory-created')

expect(() => FactoryConfigSchema.parse({
babysitter: { enabled: true, mode: 'routed-open-prs' },
repos: { names: ['pear'] },
})).toThrow(/must resolve at least one owner\/repository route/u)
})

it('accepts one-character repository opt-out identities', () => {
const parsed = FactoryConfigSchema.parse({
babysitter: {
enabled: true,
excludePullRequests: ['owner/r#1'],
},
repos: {},
})

expect(parsed.babysitter.excludePullRequests).toEqual(['owner/r#1'])
})

it('lets explicit byLabel/clonePaths/labels override the derived ones', () => {
const parsed = FactoryConfigSchema.parse({
subscription: { labels: ['pear'] },
Expand Down
51 changes: 49 additions & 2 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ const slackSchema = z.object({

const babysitterSchema = z.object({
enabled: z.boolean().default(false),
// Preserve the historical issue-created intake unless an operator
// deliberately opts into the wider routed-repository sweep. Keeping the
// rollout switch separate from `enabled` lets the identity fix in #221 land
// before automated writes are attributed across existing human PRs.
mode: z.enum(['factory-created', 'routed-open-prs']).default('factory-created'),
Comment thread
khaliqgant marked this conversation as resolved.
// This label is an author-controlled hard stop. Discovery reads it before
// spawning an agent, and routed babysitters are instructed to re-check it
// before their first provider write.
excludeLabels: z.array(z.string().trim().min(1)).default(['factory:skip-babysitter']),
excludePullRequests: z.array(z.string().regex(
/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{0,99}#[1-9]\d*$/u,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The babysitter.excludePullRequests regex was changed from [A-Za-z0-9_.-]{1,100} to [A-Za-z0-9_.-]{0,99} for the repository segment. Lowering the minimum to 0 means the validator now accepts an empty repository segment, e.g. owner/#1, which is not a real GitHub identity and can never match a scanned PR (so an operator typo silently does nothing), while simultaneously lowering the max to 99 so a legitimate 100-character repository name (GitHub's documented upper bound) is now rejected as 'expected owner/repo#number'. The added test only covers the 1-character repo owner/r#1, which already validated under the previous {1,100} range, so this regex edit does not appear necessary to achieve the stated goal. Consider restoring a 1..N range with a required first character (e.g. [A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99}) to keep 1–100 while mirroring the owner segment's shape).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/config/schema.ts, line 134:

<comment>The `babysitter.excludePullRequests` regex was changed from `[A-Za-z0-9_.-]{1,100}` to `[A-Za-z0-9_.-]{0,99}` for the repository segment. Lowering the minimum to 0 means the validator now accepts an empty repository segment, e.g. `owner/#1`, which is not a real GitHub identity and can never match a scanned PR (so an operator typo silently does nothing), while simultaneously lowering the max to 99 so a legitimate 100-character repository name (GitHub's documented upper bound) is now rejected as 'expected owner/repo#number'. The added test only covers the 1-character repo `owner/r#1`, which already validated under the previous `{1,100}` range, so this regex edit does not appear necessary to achieve the stated goal. Consider restoring a 1..N range with a required first character (e.g. `[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})` to keep 1–100 while mirroring the owner segment's shape).</comment>

<file context>
@@ -131,7 +131,7 @@ const babysitterSchema = z.object({
   excludeLabels: z.array(z.string().trim().min(1)).default(['factory:skip-babysitter']),
   excludePullRequests: z.array(z.string().regex(
-    /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{1,100}#[1-9]\d*$/u,
+    /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{0,99}#[1-9]\d*$/u,
     'expected owner/repo#number',
   )).default([]),
</file context>
Suggested change
/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9_.-]{0,99}#[1-9]\d*$/u,
/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})#[1-9]\d*$/u,

'expected owner/repo#number',
)).default([]),
// Routed intake stays quiet until an operator explicitly approves the
// notification shape observed in a read-only sweep.
notifyHumans: z.boolean().default(false),
}).default({})

const reportingSchema = z.object({
Expand Down Expand Up @@ -307,9 +323,40 @@ const NodeConfigObjectSchema = z.object({

const FactoryConfigObjectSchema = WorkspaceConfigObjectSchema.merge(NodeConfigObjectSchema)

export const WorkspaceConfigSchema = WorkspaceConfigObjectSchema.transform((cfg) => normalizeWorkspaceConfig(cfg))
const requireRoutedBabysitterRepos = (
cfg: z.infer<typeof WorkspaceConfigObjectSchema>,
ctx: z.RefinementCtx,
): void => {
if (cfg.babysitter.mode === 'routed-open-prs' && (cfg.repos.names?.length ?? 0) === 0) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['repos', 'names'],
message: 'repos.names must contain at least one repository when babysitter.mode is routed-open-prs',
})
return
}
if (cfg.babysitter.mode === 'routed-open-prs') {
const routedRepos = (cfg.repos.names ?? []).map((name) =>
cfg.repos.byLabel[name] ?? cfg.repos.overrides[name] ??

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The new requireRoutedBabysitterRepos resolution check only applies the org prefix in its final fallback (org ? ${org}/${name} : name), but the runtime path in routedPrRepos (src/github/routed-pr-babysitter.ts) applies the org prefix whenever the resolved value lacks a slash, including when it came from an explicit byLabel entry. So a config where byLabel maps a name to a bare repo name while repos.org is set would be rejected by this schema check ('must resolve at least one owner/repository route') even though routedPrRepos would route it as ${org}/${name} at runtime. Applying the same org-prefix logic to the byLabel/overrides results would keep the schema guard and the runtime scanner consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/config/schema.ts, line 340:

<comment>The new `requireRoutedBabysitterRepos` resolution check only applies the org prefix in its final fallback (`org ? `${org}/${name}` : name`), but the runtime path in `routedPrRepos` (src/github/routed-pr-babysitter.ts) applies the org prefix whenever the resolved value lacks a slash, including when it came from an explicit `byLabel` entry. So a config where `byLabel` maps a name to a bare repo name while `repos.org` is set would be rejected by this schema check ('must resolve at least one owner/repository route') even though `routedPrRepos` would route it as `${org}/${name}` at runtime. Applying the same org-prefix logic to the byLabel/overrides results would keep the schema guard and the runtime scanner consistent.</comment>

<file context>
@@ -333,6 +333,20 @@ const requireRoutedBabysitterRepos = (
+  }
+  if (cfg.babysitter.mode === 'routed-open-prs') {
+    const routedRepos = (cfg.repos.names ?? []).map((name) =>
+      cfg.repos.byLabel[name] ?? cfg.repos.overrides[name] ??
+      (cfg.repos.org ? `${cfg.repos.org}/${name}` : name)
+    )
</file context>

(cfg.repos.org ? `${cfg.repos.org}/${name}` : name)
)
if (!routedRepos.some((repo) => /^[^/]+\/[^/]+$/u.test(repo))) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['repos', 'names'],
message: 'repos.names must resolve at least one owner/repository route when babysitter.mode is routed-open-prs',
})
}
}
}

export const WorkspaceConfigSchema = WorkspaceConfigObjectSchema
.superRefine(requireRoutedBabysitterRepos)
.transform((cfg) => normalizeWorkspaceConfig(cfg))
export const NodeConfigSchema = NodeConfigObjectSchema.transform((cfg) => normalizeNodeConfig(cfg))
export const FactoryConfigSchema = FactoryConfigObjectSchema.transform((cfg) => normalizeFactoryConfig(cfg))
export const FactoryConfigSchema = FactoryConfigObjectSchema
.superRefine(requireRoutedBabysitterRepos)
.transform((cfg) => normalizeFactoryConfig(cfg))

function normalizeWorkspaceConfig(cfg: z.infer<typeof WorkspaceConfigObjectSchema>) {
const resolved = resolveRepos(cfg.repos, cfg.repos.cloneRoot)
Expand Down
10 changes: 9 additions & 1 deletion src/dispatch/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ describe('renderAgentTask', () => {
baseRef: 'main',
headRepo: 'AgentWorkforce/hoopsheet',
},
standaloneBabysitter: { specSource: 'pull-request' },
standaloneBabysitter: {
specSource: 'pull-request',
excludeLabels: ['factory:skip-babysitter'],
notifyHumans: false,
},
integrationsMountRoot: '/workspace/.integrations',
})

Expand All @@ -210,6 +214,10 @@ describe('renderAgentTask', () => {
expect(task).toContain('push the same PR head')
expect(task).toContain('re-read the live merge state and fresh checks')
expect(task).toContain('never merge it yourself')
expect(task).toContain('Before your first provider write')
expect(task).toContain('factory:skip-babysitter')
expect(task).toContain('Do not post status comments, mention humans, send notifications, or escalate')
expect(task).not.toContain('proactively offer to discuss')
expect(task).toContain('Never search for, read, or substitute credentials or tokens')
expect(task).toContain('output `/exit` on its own line')
expect(task).not.toContain('DM `broker`')
Expand Down
16 changes: 15 additions & 1 deletion src/dispatch/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export interface RenderAgentTaskInput {
*/
standaloneBabysitter?: {
specSource: 'pull-request' | 'linked-issue'
/** Labels which abort this run before its first provider write. */
excludeLabels?: string[]
/** Human-facing comments, mentions, and escalation are opt-in for sweeps. */
notifyHumans?: boolean
}
slackDispatchThread?: {
channel: string
Expand Down Expand Up @@ -222,13 +226,21 @@ export function renderAgentTask(input: RenderAgentTaskInput): string {
const standaloneMergePolicy = input.config.mergePolicy === 'on-green-with-review'
? 'Merge policy: on-green-with-review. This standalone run has no guarded merge executor, so never merge the PR yourself; leave the final merge to a human.'
: 'Merge policy: never - leave the PR open for human review and approval; never merge it yourself.'
const optOutLine = input.standaloneBabysitter.excludeLabels?.length
? `Before your first provider write and again before every later provider write, re-read the live PR labels. If any label in JSON ${JSON.stringify(input.standaloneBabysitter.excludeLabels)} is present, make no further provider writes, report the opt-out, and exit.`
: undefined
const notificationLine = input.standaloneBabysitter.notifyHumans
? 'You may notify or mention humans when a concrete decision is required.'
: 'Do not post status comments, mention humans, send notifications, or escalate this run. Only write the code, commits, pushes, and direct review-thread replies required to shepherd the PR.'
return [
`GitHub repo: ${repo}`,
cloneInstruction,
...specHeader,
'',
`You are the standalone PR babysitter for ${prRef}.`,
'Your job: drive this PR to genuinely green and correct against the definition of done above, then hand it to a human. Do NOT merge it yourself.',
...(optOutLine ? [optOutLine] : []),
notificationLine,
'Fix things directly and aggressively: inspect the existing implementation, make substantive corrections, and keep the PR scope anchored to the definition of done.',
...(branchLine ? [branchLine] : []),
checkoutLine,
Expand All @@ -242,7 +254,9 @@ export function renderAgentTask(input: RenderAgentTaskInput): string {
'After every push, wait for the checks on the newly pushed head commit. Never reuse green results from an older commit when declaring the PR ready.',
'Commit and push fixes only to the existing PR head branch. Use a normal push when possible; if rebasing requires rewriting the PR head, use `--force-with-lease`, never an unconditional force push.',
'If the push is denied, stop and report the access blocker. Never search for, read, or substitute credentials or tokens, and never modify Git/GitHub authentication configuration.',
'If a human can be reached, proactively offer to discuss the PR status, trade-offs, and open questions.',
...(input.standaloneBabysitter.notifyHumans
? ['If a human can be reached, proactively offer to discuss the PR status, trade-offs, and open questions.']
: []),
'When the PR is green — no failing CI, no merge conflicts, and every review comment addressed — report a concise completion summary and output `/exit` on its own line so the Agent Relay task-exit lifecycle closes cleanly.',
standaloneFinishLine,
standaloneMergePolicy,
Expand Down
6 changes: 6 additions & 0 deletions src/github/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export {
readStandalonePullRequest,
standaloneBabysitterAgentName,
} from './standalone-babysitter'
export {
discoverRoutedPullRequests,
routedPrIdentity,
routedPrRepos,
} from './routed-pr-babysitter'
export type {
GhRunner,
GhRunResult,
Expand All @@ -30,3 +35,4 @@ export type {
StandaloneBabysitTarget,
StandalonePullRequest,
} from './standalone-babysitter'
export type { RoutedPrCandidate, RoutedPrDiscoveryReport } from './routed-pr-babysitter'
Loading