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
36 changes: 19 additions & 17 deletions AGENT_TEST.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,20 @@ touches a layer, check that its slice (and the linked documents) still holds.
- Runtime state files: `.runtime/orchestrator/` (`workspaces/<id>/`, `runs/<run-id>/`)
- Instance registry: `${GH_SYMPHONY_INSTANCES_DIR:-${GH_SYMPHONY_CONFIG_DIR:-~/.gh-symphony}/instances/}` (mode `0700`; one file per runtime/project). Daemon runtime overrides do not change this inherited host index.

## §17 conformance test matrix

The rows below are owned by the focused conformance suites rather than a
single implementation package. They map the upstream test matrix to the
authoritative tests for repository behavior.

| Spec row | Test mapping |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| §17.2 workspace safety and hooks | `packages/orchestrator/src/service.test.ts` covers rejecting an existing regular file at the issue workspace path and running `after_create` only for a newly created workspace; `packages/core/src/workspace-safety.test.ts` covers path containment. |
| §17.3 empty tracker lookup and malformed refresh | `packages/tracker-{github,linear,file}/src/*test.ts` assert empty state/ID lookups make no provider call. GitHub and Linear suites assert that malformed requested records fail; GitHub alone covers omission of malformed polling-list items. Linear polling-list omission is a documented implementation gap. |
| §17.4 reconciliation with no running issues | `packages/orchestrator/src/service.test.ts` proves reconciliation does not invoke per-run reconciliation when there are no active runs. |
| §17.5 Codex protocol stream and dynamic-tool rejection | `packages/worker/src/codex-dynamic-tools.test.ts` covers structured rejection of unsupported dynamic tools. Stderr isolation from the protocol stream remains a documented implementation gap pending the S21 decision. |
| §13.7 host, port, and bind lifecycle | `packages/cli/src/commands/start.test.ts` covers explicit ports and loopback versus `--bind-all` host selection. §17.7 positional workflow-path behavior remains a documented divergence. |

## Package dependency graph

`packages/cli` is the published entrypoint that bundles the rest at build time
Expand Down
92 changes: 88 additions & 4 deletions packages/orchestrator/src/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,46 @@ describe("OrchestratorService", () => {
expect(snapshot.summary.recovered).toBe(1);
});

it("does not reconcile runs when the project has no active runs", async () => {
const repository = {
owner: "acme",
name: "platform",
cloneUrl: "https://github.com/acme/platform.git",
};
const projectConfig = createProjectConfig("/tmp/orchestrator", repository);
const store = {
loadProjectIssueOrchestrations: vi.fn().mockResolvedValue([]),
loadAllRuns: vi.fn().mockResolvedValue([]),
saveProjectIssueOrchestrations: vi.fn().mockResolvedValue(undefined),
loadIssueWorkspaces: vi.fn().mockResolvedValue([]),
saveProjectStatus: vi.fn().mockResolvedValue(undefined),
} as unknown as OrchestratorFsStore;
const service = new OrchestratorService(store, projectConfig);
const reconcileRun = vi.fn();
vi.spyOn(
service as never,
"selectCurrentRunsForReconciliation"
).mockResolvedValue([]);
vi.spyOn(service as never, "reconcileRun").mockImplementation(reconcileRun);
vi.spyOn(trackerAdapters, "resolveTrackerAdapter").mockImplementation(
() => {
throw new Error("Unsupported tracker adapter: retired-kind");
}
);

const snapshot = await (
service as unknown as {
reconcileProject(
tenant: OrchestratorProjectConfig
): Promise<ProjectStatusSnapshot>;
}
).reconcileProject(projectConfig);

expect(reconcileRun).not.toHaveBeenCalled();
expect(snapshot.summary.recovered).toBe(0);
expect(snapshot.lastError).toContain("Unsupported tracker adapter");
});

it("continues dispatching after an earlier candidate fails to start", async () => {
process.env.GITHUB_GRAPHQL_TOKEN = "test-token";
const tempRoot = await mkdtemp(
Expand Down Expand Up @@ -1923,6 +1963,48 @@ describe("OrchestratorService", () => {
);
});

it("fails safely when an issue workspace path is an existing regular file", async () => {
process.env.GITHUB_GRAPHQL_TOKEN = "test-token";
const tempRoot = await mkdtemp(
join(tmpdir(), "orchestrator-workspace-file-")
);
const repository = await createRepositoryFixture(
tempRoot,
"acme",
"platform"
);
const store = new OrchestratorFsStore(tempRoot);
const projectConfig = createProjectConfig(tempRoot, repository);
await store.saveProjectConfig(projectConfig);
const workspaceKey = deriveIssueWorkspaceKey("acme/platform#1");
const workspacePath = resolveIssueWorkspaceDirectory(
projectConfig.workspaceDir,
workspaceKey
);
await mkdir(projectConfig.workspaceDir, { recursive: true });
await writeFile(workspacePath, "preserve this file", "utf8");
const spawnImpl = vi.fn();
const service = new OrchestratorService(store, projectConfig, {
fetchImpl: vi.fn().mockResolvedValue(createTrackerResponse(repository)),
spawnImpl: spawnImpl as never,
now: () => new Date("2026-03-08T00:00:00.000Z"),
});

const snapshot = await service.runOnce();

expect(snapshot.summary.dispatched).toBe(0);
expect(spawnImpl).not.toHaveBeenCalled();
expect(await readFile(workspacePath, "utf8")).toBe("preserve this file");
expect(
await store.loadProjectIssueOrchestrations(projectConfig.projectId)
).toEqual([
expect.objectContaining({
identifier: "acme/platform#1",
state: "retry_queued",
}),
]);
});

it.each([
{
name: "closed source issue",
Expand Down Expand Up @@ -9064,10 +9146,12 @@ Prefer focused changes.
dueAt: "2026-03-08T00:00:07.000Z",
error: "Worker process exited unexpectedly.",
});
const events = (await readFile(
join(store.runDir("run-1", "tenant-1"), "events.ndjson"),
"utf8"
))
const events = (
await readFile(
join(store.runDir("run-1", "tenant-1"), "events.ndjson"),
"utf8"
)
)
.trim()
.split("\n")
.map((line) => JSON.parse(line));
Expand Down
11 changes: 11 additions & 0 deletions packages/tracker-file/src/file-tracker-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,17 @@ describe("fileTrackerAdapter", () => {
});

describe("listIssuesByStates", () => {
it("returns without reading the provider for empty state and ID lookups", async () => {
const project = makeProject(join(testDir, "missing-issues.json"));

await expect(
fileTrackerAdapter.listIssuesByStates(project, [])
).resolves.toEqual([]);
await expect(
fileTrackerAdapter.fetchIssueStatesByIds(project, [])
).resolves.toEqual([]);
});

it("filters issues to the requested workflow states", async () => {
const issuesPath = join(testDir, "issues.json");
await writeFile(
Expand Down
24 changes: 24 additions & 0 deletions packages/tracker-github/src/tracker-github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2380,6 +2380,30 @@ Prompt`,
expect(issues.map((issue) => issue.state)).toEqual(["Done"]);
});

it("does not call GitHub for empty state or ID lookups", async () => {
const adapter = resolveTrackerAdapter({
adapter: "github-project",
bindingId: "project-123",
settings: { projectId: "project-123" },
});
const fetchImpl = vi.fn();

await expect(
adapter.listIssuesByStates(makeProjectConfig(), [], {
token: "dependencies-token",
fetchImpl,
})
).resolves.toEqual([]);
await expect(
adapter.fetchIssueStatesByIds(makeProjectConfig(), [], {
token: "dependencies-token",
fetchImpl,
})
).resolves.toEqual([]);

expect(fetchImpl).not.toHaveBeenCalled();
});

it("keeps the custom lifecycle field when explicit state lookups disable server filtering", async () => {
const adapter = resolveTrackerAdapter({
adapter: "github-project",
Expand Down
39 changes: 39 additions & 0 deletions packages/tracker-linear/src/tracker-linear.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,25 @@ Prompt`,
);
});

it("does not call Linear for empty state or ID lookups", async () => {
const fetchImpl = vi.fn();

await expect(
linearTrackerAdapter.listIssuesByStates(makeProject(), [], {
fetchImpl,
token: "linear-token",
})
).resolves.toEqual([]);
await expect(
linearTrackerAdapter.fetchIssueStatesByIds(makeProject(), [], {
fetchImpl,
token: "linear-token",
})
).resolves.toEqual([]);

expect(fetchImpl).not.toHaveBeenCalled();
});

it("fetchIssueStatesByIds filters by Linear ids", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
jsonResponse({
Expand Down Expand Up @@ -1211,6 +1230,26 @@ Prompt`,
});
});

it("fails when a requested Linear issue has malformed required state data", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
jsonResponse({
data: {
issues: {
nodes: [linearIssueNode("ENG-123", [], { state: null })],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
})
);

await expect(
linearTrackerAdapter.fetchIssueStatesByIds(makeProject(), ["ENG-123"], {
fetchImpl,
token: "linear-token",
})
).rejects.toThrow("Linear issue state name is required.");
});

it("injects worker environment without requiring team id", () => {
const env = linearTrackerAdapter.buildWorkerEnvironment(
makeProject({ apiUrl: undefined }),
Expand Down
Loading