Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/turn-routability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gh-symphony/cli": patch
---

Stop a worker before its next turn when a refreshed tracker snapshot shows the issue is no longer dispatchable or no longer satisfies `required_labels` (#722).
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ packages/control-plane/client/src/routeTree.gen.ts

# E2E runtime file (injected at test time, not committed)
e2e/fixtures/issues.json
e2e/fixtures/required-label-removed.signal
2 changes: 1 addition & 1 deletion AGENT_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ Control worker behavior with the `STUB_SCENARIO` environment variable:
| Opaque tracker native reference boundary | `packages/tracker-github/src/tracker-github.test.ts` exercises GitHub linked-PR canonicalization through `resolveCanonicalIssues`; `packages/orchestrator/src/dispatch.test.ts` verifies dispatch uses adapter hooks rather than service-level provider payload inspection. | TC-18 runs the Docker `happy` scenario with the file tracker fixture, which derives its opaque item reference and completes dispatch without provider-specific orchestration branches. |
| Configured repository workflow path and startup preflight | `packages/cli/src/commands/start.test.ts` verifies unsupported tracker kinds fail before daemon construction, project `.env` values resolve during preflight, and missing configured files give a remediation. `packages/orchestrator/src/service.test.ts` verifies repo-typed configured paths are loaded and warn when shadowing the repository root workflow; `doctor.test.ts` and `repo-explain.test.ts` verify diagnostics use the same path. | `./e2e/run-e2e.sh happy 60` confirms the Docker repository-init → repository-start lifecycle and worker dispatch remain healthy after startup-validation changes. The custom-path and invalid-kind edge cases are unit-isolated because the standard Docker fixture uses a fixed repository policy. |
| Adapter-owned dispatch eligibility | `packages/orchestrator/src/dispatch.test.ts` verifies the scheduler suppresses `dispatchable: false` issues, while each tracker adapter's suite verifies its own provider-specific derivation. | `./e2e/run-e2e.sh non-dispatchable 30` injects a file-tracker issue with `dispatchable: false`, waits for two post-injection reconciliation ticks, confirms no worker or `run-dispatched` event exists, and verifies `repo explain` retains the adapter reason. |
| Required-label routability | `packages/orchestrator/src/dispatch.test.ts` verifies normalized missing-label explanations and active-run cancellation without workspace cleanup; `packages/orchestrator/src/service.test.ts` verifies a due retry losing its required label releases its claim and persists the routability reason. | TC-19 removes a required label from an active file-tracker issue, confirms SIGTERM and retained workspace, and checks `repo explain` reports the missing-label reason. |
| Required-label routability | `packages/worker/src/turn-lease.test.ts` verifies a confirmed active state with `routable: false` stops before the next turn; `packages/orchestrator/src/dispatch.test.ts` verifies normalized missing-label explanations and active-run cancellation without workspace cleanup; `packages/orchestrator/src/service.test.ts` verifies a due retry losing its required label releases its claim and persists the routability reason. | [TC-19](e2e/scenarios/19-required-label-routability.md) verifies that a missing required label prevents dispatch, then removes the label after a deterministic stub's first turn and asserts that its actual worker `state-read` prevents turn two. |
| Shared worktree-cache agent branch collection | `packages/orchestrator/src/git.test.ts` verifies that every detached ref under `refs/heads/` in the shared bare cache is deleted only when its tip is reachable from `refs/remotes/origin/*`; unpushed branches and branches linked to live worktrees are retained, including branches from other projects sharing the cache. | The Docker file tracker does not create real agent commits or remote branches; repository-fixture coverage is the authoritative TC for this Git reachability guarantee. |
| Normalized per-state concurrency limits | `packages/core/src/workflow-loader.test.ts` verifies trimmed/lowercased map keys and ignored invalid entries; `packages/orchestrator/src/dispatch.test.ts` verifies a padded mixed-case key caps matching tracker states; `packages/orchestrator/src/explain.test.ts` verifies `repo explain` reports the same mixed-case per-state cap as dispatch. | `./e2e/run-e2e.sh happy 60` confirms the Docker dispatch lifecycle remains healthy with the orchestrator's canonical workflow-state lookup path. |

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ gh-symphony project list # List cached standalone projects
gh-symphony instances --json # List active repository and standalone instances
```

The project folder is the source of truth and the address: every command derives the runtime from the folder's `WORKFLOW.md` on each start, so editing the workflow takes effect on the next start with no registration step. `project start --help` lists its runtime flags, including `--once`, `--daemon`, `--assigned-only`, `--allow-duplicate`, `--bind-all`, `--http`, `--web`, `--log-level`, and `--project-dir`. `--assigned-only` is input to the tracker adapter's `dispatchable` derivation; the scheduler consumes that normalized eligibility result rather than interpreting provider-specific assignment rules. A verified live instance for the same project in another runtime is rejected by default; use `--allow-duplicate` only for intentional isolation. Starting refuses a tracker mapping that overlaps a project already running against the same repository, and asks for confirmation when the overlapping project is stopped. Two projects on one repository stay disjoint through `tracker.pickup_labels`, which the GitHub, Linear, and file trackers all apply when listing dispatch candidates. Label comparison is case-insensitive and ignores surrounding whitespace, so `Agent`, `agent`, and `" AGENT "` are the same label. `repository.clone_url` overrides the derived clone URL for mirrors, Enterprise hosts, or local paths. See [docs/configuration.md](docs/configuration.md) for the project `.env` loading order and skill layering details.
The project folder is the source of truth and the address: every command derives the runtime from the folder's `WORKFLOW.md` on each start, so editing the workflow takes effect on the next start with no registration step. `project start --help` lists its runtime flags, including `--once`, `--daemon`, `--assigned-only`, `--allow-duplicate`, `--bind-all`, `--http`, `--web`, `--log-level`, and `--project-dir`. `--assigned-only` is input to the tracker adapter's `dispatchable` derivation; the scheduler consumes that normalized eligibility result rather than interpreting provider-specific assignment rules. A verified live instance for the same project in another runtime is rejected by default; use `--allow-duplicate` only for intentional isolation. Starting refuses a tracker mapping that overlaps a project already running against the same repository, and asks for confirmation when the overlapping project is stopped. Two projects on one repository stay disjoint through `tracker.provider.pickup_labels.include`, which GitHub and Linear apply as an any-match candidate pre-filter. `tracker.required_labels` is separate: every configured label must remain present for an issue to be routable, including between worker turns. Label comparison is case-insensitive and ignores surrounding whitespace, so `Agent`, `agent`, and `" AGENT "` are the same label. `repository.clone_url` overrides the derived clone URL for mirrors, Enterprise hosts, or local paths. See [docs/configuration.md](docs/configuration.md) for the project `.env` loading order and skill layering details.

### Official Container Deployment

Expand Down Expand Up @@ -595,7 +595,7 @@ tracker:

`gh-symphony repo start --assigned-only` also applies to Linear trackers. It is an input to the Linear adapter's `dispatchable` derivation: the adapter keeps candidate issues observable, compares each returned `assignee.id` with the authenticated viewer, and marks nonmatching or unassigned issues non-dispatchable. With a personal API key this viewer is that person; with a service-account key it is the service account. Symphony does not fail fast because Linear does not expose enough token metadata in the issue query path to distinguish those cases reliably.

Linear workflows may also configure `tracker.provider.pickup_labels.include` and `tracker.provider.pickup_labels.exclude` as routing gates. Excluded labels always win; when include labels are configured, an issue needs at least one include label before a worker starts. Label comparison is case-insensitive and ignores surrounding whitespace, so labels that differ only by case or outer whitespace cannot be used as separate gates. Removing a required routing label blocks new dispatches and due retries; on the next reconciliation tick it terminates an already running worker while preserving its workspace for recovery.
GitHub and Linear workflows may configure `tracker.provider.pickup_labels.include` and `tracker.provider.pickup_labels.exclude` as candidate filters. Excluded labels always win; when include labels are configured, an issue needs any one include label before it is considered for dispatch. On GitHub, this pre-filter does not terminate an already-running worker when its labels change. Linear applies the pickup filter to ID refreshes too, so removing the sole included label can make an active worker stop during reconciliation. By contrast, `tracker.required_labels` is an all-of routability gate: removing one blocks new dispatches and due retries, and the worker stops before its next turn after a refreshed tracker read reports the issue is no longer routable. Label comparison is case-insensitive and ignores surrounding whitespace, so labels that differ only by case or outer whitespace cannot be used as separate gates.

Linear orchestration is polling-only. There is intentionally no Linear webhook setup command; state transitions, workpad comments, and PR handoff policy belong in `WORKFLOW.md`. See `docs/examples/linear-WORKFLOW.md` for a complete example.

Expand Down
1 change: 1 addition & 0 deletions docker-compose.e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ services:
- /e2e/work:size=100M,uid=1000,gid=1000,mode=1777
environment:
STUB_SCENARIO: ${STUB_SCENARIO:-happy}
E2E_REQUIRED_LABELS: ${E2E_REQUIRED_LABELS:-}
GH_SYMPHONY_HTTP_TOKEN: ${GH_SYMPHONY_HTTP_TOKEN:-e2e-http-token}
20 changes: 15 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,23 @@ already occupied. A bare `--port` or `--http` keeps the legacy default-port
auto-increment behavior, while omitting both options and `server.port` uses an
ephemeral internal listener.

`tracker.required_labels` defaults to `[]`. Labels are compared after trimming
and lowercasing; every configured label must be present before an issue can be
routed. A blank configured label is preserved and therefore matches no issue.
Removing a required label blocks new dispatches and due retries. On the next
reconciliation tick, an active worker for that issue is terminated without
`tracker.required_labels` defaults to `[]`. It is an ALL-of routability gate:
labels are compared after trimming and lowercasing, and every configured label
must be present before an issue can be routed. A blank configured label is
preserved and therefore matches no issue. Removing a required label blocks new
dispatches and due retries; a worker also stops before its next turn when its
authenticated tracker-state read returns a refreshed, non-routable snapshot.
Reconciliation still terminates an active worker on its next tick without
workspace cleanup so its work remains available for recovery.

`tracker.provider.pickup_labels.include` is separate from
`tracker.required_labels`. For GitHub and Linear it is an ANY-of candidate
pre-filter: an issue needs at least one include label to enter the dispatch
candidate set. `exclude` always wins. Pickup-label changes affect future
candidate listing only; they do not make an already-running issue non-routable
or stop its worker. Use `required_labels` when the label must remain true
throughout a run.

## Label and Timestamp Normalization

Core normalizes workflow pickup labels and tracker-provided labels before label
Expand Down
20 changes: 20 additions & 0 deletions e2e/fixtures/required-label-active.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
{
"id": "issue-required-label-1",
"identifier": "test-owner/test-repo#1",
"number": 1,
"title": "Required label removed during run",
"description": "This issue begins routable, then loses its label.",
"priority": null,
"state": "Ready",
"branchName": null,
"url": "https://tracker.example.test/issues/issue-required-label-1",
"labels": ["agent"],
"blockedBy": [],
"createdAt": "2026-03-17T00:00:00Z",
"updatedAt": "2026-03-17T00:00:00Z",
"repository": { "owner": "test-owner", "name": "test-repo", "cloneUrl": "/e2e/repos/test-owner/test-repo" },
"tracker": { "adapter": "file", "bindingId": "e2e-test", "itemId": "issue-required-label-1" },
"metadata": {}
}
]
20 changes: 20 additions & 0 deletions e2e/fixtures/required-label-missing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[
{
"id": "issue-required-label-1",
"identifier": "test-owner/test-repo#1",
"number": 1,
"title": "Missing required label",
"description": "This issue must not dispatch.",
"priority": null,
"state": "Ready",
"branchName": null,
"url": "https://tracker.example.test/issues/issue-required-label-1",
"labels": [],
"blockedBy": [],
"createdAt": "2026-03-17T00:00:00Z",
"updatedAt": "2026-03-17T00:00:00Z",
"repository": { "owner": "test-owner", "name": "test-repo", "cloneUrl": "/e2e/repos/test-owner/test-repo" },
"tracker": { "adapter": "file", "bindingId": "e2e-test", "itemId": "issue-required-label-1" },
"metadata": {}
}
]
Loading
Loading