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
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,25 @@ collaborative state and external implementation runs are durable.
`LIVE_SPIKES` prototypes run only passages the consenting editor changed, on a
push-refusing throwaway worktree, under hourly caps and a 20-minute deadline;
see [Prototype spikes](docs/local-experiments.md#prototype-spikes-live_spikes).
- **A shared machine runs teammates' work as its owner.** An owner who shares a machine
consents to the Planner, builds and runs of a document whose editor chose it, in repositories
the owner can push to (checked with the machine's own token, as Default machine does). The
agent runs with the owner's GitHub account; the build records the editor as `user` and the
owner as `machineOwner`, and only the connection it was queued on may claim or report it.
Automatic work never leaves its builder's own machines. Where a document's Planner runs is
derived from stored choices (the creator's, else an editor's whose machine can run it now),
never from process memory, so a restart asks one machine; a choice that cannot run it yet
stops nothing and never interrupts an attached Planner. That check is held per machine and
repository (a pass 10 minutes, a refusal a minute, asked once however many listings ask
at once). The Machines page, polled every few seconds, lists a machine that serves every
repository from two held lists of what can be pushed to, not a check per repository: the
person's own (a minute) and its owner's, listed with the machine's token once per owner
for everyone (10 minutes, a failure a minute), so its cost to the owner's token does not
grow with teammates or polls. Only a repository the machine has a checkout of is asked
on its own. Unsharing clears teammates' choices of the
machine and hands a document to Default machine only where the machine ran its Planner,
had been asked to, was placing it, or someone uses the document; any other just loses
the choice.
- **Persistence should precede publication.** Do not acknowledge or broadcast a
domain mutation before its fenced durable commit.

Expand Down
12 changes: 12 additions & 0 deletions apps/connector/src/implementation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,15 @@ test.each([
expect(text).toContain("use your best judgment");
expect(text).toContain("pullRequestSection, unchanged");
});

test.each([
["a build", implementationPrompt, "read_implementation"],
["a living document's first build", liveImplementationPrompt, "read_implementation"],
["a rebuild", rebuildPrompt, "read_rebuild"],
])("%s asked for by a teammate says so in every pull request it opens", (_name, prompt, read) => {
let text = prompt(worktrees);
expect(text).toContain(`If ${read} returns requestedBy, a teammate asked for this build`);
expect(text).toContain(
"put its pullRequestNote, unchanged, at the end of the body of every pull request",
);
});
15 changes: 15 additions & 0 deletions apps/connector/src/implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ function openDecisionLines(read: "read_implementation" | "read_rebuild") {
];
}

/**
* A build asked for by a teammate runs on this machine with its owner's GitHub account, so its pull
* requests come from the owner. The server says whose request it was; the agent passes that on.
*/
function requestedByLines(read: "read_implementation" | "read_rebuild") {
return [
`If ${read} returns requestedBy, a teammate asked for this build: put its pullRequestNote,`
+ " unchanged, at the end of the body of every pull request you open or update, so the"
+ " pull request says whose request it was.",
];
}

export function implementationPrompt(worktrees: RunWorktrees) {
return [
"Read the human-approved graph and document with read_implementation from Chopin's MCP server.",
Expand All @@ -116,6 +128,7 @@ export function implementationPrompt(worktrees: RunWorktrees) {
"Open small reviewable draft pull requests (`gh pr create --draft`), linking the document URL",
"and its #task-ID anchors. Never merge PRs.",
...openDecisionLines("read_implementation"),
...requestedByLines("read_implementation"),
"If a task needs no code change, open no pull request: call complete_task with noChange, giving",
"the reason and Markdown evidence of what you checked. Its dependents build on the same base.",
"If a new human decision is needed, block the affected task with its question and stop that work.",
Expand All @@ -138,6 +151,7 @@ export function liveImplementationPrompt(worktrees: RunWorktrees) {
"Open small reviewable draft pull requests (`gh pr create --draft`), linking the document URL",
"and its #task-ID anchors. Never merge PRs.",
...openDecisionLines("read_implementation"),
...requestedByLines("read_implementation"),
"If a task needs no code change, open no pull request: call complete_task with noChange, giving",
"the reason and Markdown evidence of what you checked. Its dependents build on the same base.",
"If a new human decision is needed, block the affected task with its question and stop that work.",
Expand All @@ -163,6 +177,7 @@ export function rebuildPrompt(worktrees: RunWorktrees) {
"that has none.",
"Never open any other pull request. Never merge.",
...openDecisionLines("read_rebuild"),
...requestedByLines("read_rebuild"),
"Code that fits no existing pull request goes on the most closely related one.",
"If the change needs no code and nothing is outstanding, make no commits.",
"Finally call report_rebuild with a summary, every commit you pushed, and one task per new change.",
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/auth/machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export type PairMachine = {
label: string;
repositories: MachineRepository[];
autoRun: boolean;
/** Whether teammates' documents may run their Planner here on Default machine. */
/** Whether teammates may choose this machine for their documents' Planners, builds and runs. */
shared?: boolean;
/** The machine's own GitHub authorization; GitHub refresh tokens are single-use, never shared. */
grant?: GitHubTokenGrant;
Expand Down Expand Up @@ -144,6 +144,11 @@ export class Machines {
return this.#storage.machines.list(userId);
}

/** Every live machine its owner shares with teammates. */
shared(): Promise<StoredMachine[]> {
return this.#storage.machines.shared();
}

/** Rename the owner's live machine; false for another person's or a revoked one. */
rename(id: string, userId: string, label: string): Promise<boolean> {
return this.#storage.machines.rename(id, userId, label);
Expand Down
41 changes: 35 additions & 6 deletions apps/server/src/experiments/connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ export type Connection = {
*/
autoRun?: boolean;
/**
* Its owner shares the machine with teammates: Default machine may run the Planner of a
* document whose creator has no machine that can. Stored with the machine.
* Its owner shares the machine with teammates, who may choose it for a document's work; Default
* machine may also run the Planner of a document whose creator has no machine that can. Stored
* with the machine.
*/
shared?: boolean;
/** The chopin release that opened it, when it said; known only while it is connected. */
Expand Down Expand Up @@ -446,11 +447,39 @@ export class Connections {
|| Number(b.id === last) - Number(a.id === last)
);
}
/** Whether a connection may take its owner's work on `documentId`, as candidates() decides. */
allows(documentId: string, connection: Connection) {
/**
* The live connections of the teammate's shared machine `owner` chose for `documentId`. Whether
* its owner can push to the repository is checked where work is queued and claimed.
*/
chosenShared(repositoryId: string, owner: string, documentId: string) {
let chosen = this.chosen(documentId, owner);
return !chosen || !this.#choicesRead
? []
: this.list(repositoryId).filter(value =>
value.machineId === chosen && value.owner !== owner && value.shared
);
}
/**
* The connections that may take the work `owner` asks for on `documentId`: those candidates()
* offers, then the teammate's shared machine they chose. Never for automatic work, which stays
* on its own owner's machines.
*/
requested(repositoryId: string, owner: string, documentId: string) {
return [
...this.candidates(repositoryId, owner, documentId),
...this.chosenShared(repositoryId, owner, documentId),
];
}
/**
* Whether a connection may take `person`'s work on `documentId` (its owner's unless said), as
* candidates() decides. A teammate's machine takes only the work of someone who chose it.
*/
allows(documentId: string, connection: Connection, person = connection.owner) {
if (!this.#choicesRead) return false;
let chosen = this.chosen(documentId, connection.owner);
return chosen === undefined || connection.machineId === chosen;
let chosen = this.chosen(documentId, person);
return connection.owner === person
? chosen === undefined || connection.machineId === chosen
: connection.machineId === chosen;
}
/**
* Whether the stored choices have been read. Until they are, no connection takes anyone's
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/experiments/pairing-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const KEY = /^chopin_k_[A-Za-z0-9_-]{43}$/;

/**
* `autoRun`: whether the machine it pairs lets Chopin build and prototype automatically.
* `shared`: whether it runs teammates' Planners on Default machine.
* `shared`: whether teammates may choose it, so it runs their Planners, builds and runs.
*/
type Key = {
userId: string;
Expand Down
Loading
Loading