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
6 changes: 4 additions & 2 deletions docs/contributing/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ system without knowing which operation or delivery host called them. Adding a
new operation does not require adding a station to a shared lifecycle.

The current worker registers three independent functions: polling, readiness
routing, and triage. Spec and implementation requests are typed but remain
disabled until they have their own consumers.
routing, and triage. The composition's enabled routes also choose the states the
poller observes: Backlog is always observed, and Open is added only when a Spec
or Implement consumer is registered. Spec and implementation requests are typed
but remain disabled until they have their own consumers.

### Automation construction contract

Expand Down
35 changes: 23 additions & 12 deletions docs/contributing/linear-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,27 @@ or a Linear webhook.

```text
one-minute Inngest cron
-> list configured project Backlog revisions
-> linear/issue.revision-observed
-> reload complete Linear context
-> classify readiness
-> work/triage.requested
-> triage decision and Linear projection
-> list the configured project's observed states
-> Backlog: linear/issue.revision-observed
-> Open: linear/issue.readiness-check.requested
-> reload complete Linear context and classify readiness
-> work/triage.requested
-> work/spec.requested or work/implementation.requested
```

Linear remains the queue. A Backlog issue without an Agent action label needs
triage. Successful triage moves it out of Backlog. Work identity includes the
issue revision, so repeated observation of one revision converges while a later
human change can request new work.

Open is observed only when the worker composition enables Spec or Implement and
registers the matching consumer. Each poll cycle gives an Open readiness check
new delivery identity because blocker state can change without changing the
blocked issue's revision. The router reloads current labels, state, and blockers,
then emits a work request whose identity comes from that current readiness
snapshot. Repeated checks of an unchanged snapshot therefore converge, while a
resolved blocker produces new work.

## Workflow contract

Statuses say who owns the next move. Agent action labels say what an agent
Expand Down Expand Up @@ -81,9 +89,10 @@ secrets.
}
```

The initial worker composes one configured project. The standalone Linear read
operation accepts explicit team, project, and state IDs so another worker can
reuse it without adding a shared scheduler or project registry.
The initial worker composes one configured project. Its route map controls both
which consumers exist and which states the poller observes. The standalone
Linear read operation accepts explicit team, project, and state IDs so another
worker can reuse it without adding a shared scheduler or project registry.

## Run the local Compose stack

Expand Down Expand Up @@ -178,13 +187,15 @@ project per Compose stack until app and function identities become project-aware

The worker registers exactly three functions:

- the poller lists at most 250 matching issue revisions every minute and fails
visibly if that bound is exceeded;
- the poller lists at most 250 issue revisions per observed state every minute
and fails the whole poll visibly if any state exceeds that bound;
- the readiness router reloads complete current context and emits a
provider-neutral work request; and
- the triage consumer invokes the configured agent and projects the decision.

Spec and Implement routes remain disabled. The poller accepts an explicit
Spec and Implement routes remain disabled, so the current composition observes
Backlog only. Enabling either route adds Open observation in the same composition
change that registers its consumer. The poller accepts an explicit
`linear/poll.requested` event for deterministic smoke coverage and immediate
operator checks, but cron is the only automatic trigger.

Expand Down
137 changes: 0 additions & 137 deletions lib/linear-automation/backlog-poller.test.ts

This file was deleted.

78 changes: 0 additions & 78 deletions lib/linear-automation/backlog-poller.ts

This file was deleted.

47 changes: 47 additions & 0 deletions lib/linear-automation/events/linear-readiness-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
createLinearIssueReadinessCheckRequestedEvent,
LINEAR_ISSUE_READINESS_CHECK_EVENT_ID_PREFIX,
LINEAR_ISSUE_READINESS_CHECK_EVENT_NAME,
LINEAR_ISSUE_READINESS_CHECK_EVENT_VERSION,
LinearIssueReadinessCheckDataSchema,
LinearIssueReadinessCheckRequestedEvent,
linearIssueReadinessCheckEventId,
} from "./linear-readiness-events.ts";

const issue = {
issueId: "issue-1",
issueIdentifier: "FER-270",
};

describe("Linear readiness-check events", () => {
it("locks a strict versioned identity-only contract", () => {
expect(LinearIssueReadinessCheckRequestedEvent).toMatchObject({
name: LINEAR_ISSUE_READINESS_CHECK_EVENT_NAME,
version: LINEAR_ISSUE_READINESS_CHECK_EVENT_VERSION,
});
expect(LinearIssueReadinessCheckDataSchema.safeParse(issue).success).toBe(true);
expect(
LinearIssueReadinessCheckDataSchema.safeParse({
...issue,
updatedAt: "2026-07-22T01:00:00.000Z",
}).success,
).toBe(false);
});

it("uses the poll cycle for delivery identity without adding it to event data", () => {
const first = linearIssueReadinessCheckEventId(issue, "poll-cycle-1");
const retried = linearIssueReadinessCheckEventId({ ...issue }, "poll-cycle-1");
const nextCycle = linearIssueReadinessCheckEventId(issue, "poll-cycle-2");

expect(first).toBe(retried);
expect(first).not.toBe(nextCycle);
expect(first).toMatch(new RegExp(`^${LINEAR_ISSUE_READINESS_CHECK_EVENT_ID_PREFIX}`));
expect(createLinearIssueReadinessCheckRequestedEvent(issue, "poll-cycle-1")).toMatchObject({
id: first,
name: LINEAR_ISSUE_READINESS_CHECK_EVENT_NAME,
v: LINEAR_ISSUE_READINESS_CHECK_EVENT_VERSION,
data: issue,
});
});
});
54 changes: 54 additions & 0 deletions lib/linear-automation/events/linear-readiness-events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { createHash } from "node:crypto";
import { eventType } from "inngest";
import { z } from "zod";

export const LINEAR_ISSUE_READINESS_CHECK_EVENT_NAME = "linear/issue.readiness-check.requested";
export const LINEAR_ISSUE_READINESS_CHECK_EVENT_VERSION = "1";
export const LINEAR_ISSUE_READINESS_CHECK_EVENT_ID_PREFIX = "linear-issue-readiness-check-v1:";

const nonEmptyStringSchema = z.string().refine((value) => value.trim() !== "");

export const LinearIssueReadinessCheckDataSchema = z
.object({
issueId: nonEmptyStringSchema,
issueIdentifier: nonEmptyStringSchema,
})
.strict();

export type LinearIssueReadinessCheckData = Readonly<
z.infer<typeof LinearIssueReadinessCheckDataSchema>
>;

export const LinearIssueReadinessCheckRequestedEvent = eventType(
LINEAR_ISSUE_READINESS_CHECK_EVENT_NAME,
{
schema: LinearIssueReadinessCheckDataSchema,
version: LINEAR_ISSUE_READINESS_CHECK_EVENT_VERSION,
},
);

export function linearIssueReadinessCheckEventId(
data: LinearIssueReadinessCheckData,
pollCycleId: string,
): string {
const parsed = LinearIssueReadinessCheckDataSchema.parse(data);
const parsedPollCycleId = nonEmptyStringSchema.parse(pollCycleId);
const identity = [
LINEAR_ISSUE_READINESS_CHECK_EVENT_NAME,
LINEAR_ISSUE_READINESS_CHECK_EVENT_VERSION,
parsed.issueId,
parsedPollCycleId,
];
const digest = createHash("sha256").update(JSON.stringify(identity)).digest("hex");
return `${LINEAR_ISSUE_READINESS_CHECK_EVENT_ID_PREFIX}${digest}`;
}

export function createLinearIssueReadinessCheckRequestedEvent(
data: LinearIssueReadinessCheckData,
pollCycleId: string,
) {
const parsed = LinearIssueReadinessCheckDataSchema.parse(data);
return LinearIssueReadinessCheckRequestedEvent.create(parsed, {
id: linearIssueReadinessCheckEventId(parsed, pollCycleId),
});
}
Loading