Compare commits
2
Commits
8b6836f5a4
..
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a183975b8a | ||
|
|
52e98c9d78 |
@@ -48,14 +48,16 @@ assignment is accepted for compatibility but deprecated and ignored; orchestrati
|
||||
## Multi-work
|
||||
|
||||
Select `multi-work` in a verified live Herdr root checkout to delegate local retrieval
|
||||
to `fast-lo` and independent work to `coordinate` children in new Herdr worktrees.
|
||||
to `fast-lo` and independent work to `coordinate` children in either the current root
|
||||
checkout or new Herdr worktrees.
|
||||
It is primary-only and, like `coordinate`, uses the `deep-lo` model and effort,
|
||||
falling back to `good-lo` when `deep-lo` is unavailable. Clients viewing the
|
||||
same eligible location can see it; eligibility is revalidated before creation.
|
||||
|
||||
Configure `experimental.subagent_depth: 2` at the root and worktree locations.
|
||||
The native `subagent` tool accepts `worktree: { base: "source-branch" }` for new
|
||||
coordinate tasks and retains native session/job handling. See [usage, permissions,
|
||||
For new coordinate tasks, omit `worktree` to share the current root checkout, or pass
|
||||
`worktree: { base: "source-branch" }` for an isolated checkout. The tool retains native
|
||||
session/job handling. See [usage, permissions,
|
||||
failure handling, and verification limits](docs/multi-work.md).
|
||||
|
||||
## Playwright
|
||||
|
||||
+37
-13
@@ -1,13 +1,15 @@
|
||||
# Multi-work
|
||||
|
||||
`multi-work` is a **primary-only** agent for coordinating independent work in
|
||||
Herdr-managed Git worktrees. Like `coordinate`, it uses the configured `deep-lo`
|
||||
model and effort, falling back to `good-lo` when `deep-lo` is unavailable. No
|
||||
extra custom model assignment is required.
|
||||
the current Herdr root checkout or new Herdr-managed Git worktrees. Like
|
||||
`coordinate`, it uses the configured `deep-lo` model and effort, falling back
|
||||
to `good-lo` when `deep-lo` is unavailable. No extra custom model assignment is
|
||||
required.
|
||||
|
||||
```text
|
||||
multi-work (root checkout)
|
||||
├── fast-lo (local retrieval)
|
||||
├── coordinate (current root checkout)
|
||||
└── coordinate (new Herdr worktree)
|
||||
└── existing leaf workers (that worktree)
|
||||
```
|
||||
@@ -56,7 +58,24 @@ plugins or project configuration.
|
||||
|
||||
## Delegation
|
||||
|
||||
Multi-work retains the native tool name, `subagent`. It adds one argument:
|
||||
Multi-work retains the native tool name, `subagent`. For work in the current root
|
||||
checkout, omit `worktree`:
|
||||
|
||||
```js
|
||||
subagent({
|
||||
agent: "coordinate",
|
||||
description: "Implement local change",
|
||||
prompt: "Goal, owned files, constraints, and acceptance checks...",
|
||||
background: true,
|
||||
});
|
||||
```
|
||||
|
||||
The child is bound to the verified root workspace before its prompt is admitted.
|
||||
No Herdr workspace is created and neither the child nor parent is moved. Root-local
|
||||
children share a checkout, so parallel assignments must have disjoint file ownership.
|
||||
Their placement metadata uses `branch: "root"` to identify the shared root checkout;
|
||||
it is not a claim about the root's current Git branch.
|
||||
Use a worktree when edits may overlap or require branch isolation:
|
||||
|
||||
```js
|
||||
subagent({
|
||||
@@ -86,17 +105,22 @@ checkout path; the tool parses and verifies the returned checkout/workspace.
|
||||
Creation explicitly preserves focus with `--no-focus`.
|
||||
|
||||
For focused retrieval, use native `subagent` with `agent: "fast-lo"` and no
|
||||
`worktree`. Resume an owned coordinate child with its `sessionID` and **omit**
|
||||
`worktree`. Resumes verify parent, agent, checkout, branch, and live Herdr ownership.
|
||||
`worktree`. A new coordinate call without `worktree` means root-local work. Resume
|
||||
an owned coordinate child with its `sessionID` and **omit**
|
||||
`worktree`. Resumes verify parent, agent, assigned checkout, and live Herdr ownership;
|
||||
worktree resumes also verify the branch.
|
||||
Bound coordinate children cannot be repurposed through native delegation.
|
||||
|
||||
Neither multi-work nor coordinate has general shell/edit permissions. Multi-work
|
||||
also rejects non-reading/non-delegation tools at execution, including Code Mode,
|
||||
even if a session permission override would expose them. Worktree preparation
|
||||
requires effective `herdr_worktree: allow`, evaluated against the canonical root
|
||||
directory using ordered agent and session rules. `ask` fails explicitly because
|
||||
the public plugin API cannot request custom-tool approval. Native subagent
|
||||
permissions are still asserted by OpenCode before preparation starts.
|
||||
even if a session permission override would expose them. Creating or resuming
|
||||
worktree-backed delegation requires effective `herdr_worktree: allow`, evaluated
|
||||
against the canonical root directory using ordered agent and session rules. `ask`
|
||||
fails explicitly because the public plugin API cannot request custom-tool approval.
|
||||
Native subagent permissions are still asserted by OpenCode before preparation
|
||||
starts. Root-local delegation performs no Herdr creation and therefore does not
|
||||
require `herdr_worktree`; worker edit/shell permissions remain inherited and
|
||||
enforced normally.
|
||||
|
||||
Worktree permissions inherit the base plugin's **effective build-permission
|
||||
snapshot**, including configuration applied after plugin transforms. Its existing
|
||||
@@ -118,8 +142,8 @@ child ID, and confirm its actual session location before allowing the task promp
|
||||
A move-request acknowledgement alone is insufficient. Confirmation is bounded to
|
||||
30 seconds and remains interruptible. The parent session does not move.
|
||||
|
||||
Coordinate is child-capable only through an owned, ready worktree binding. Prompt
|
||||
admission and model-context guards reject unbound or moved coordinate children.
|
||||
Coordinate is child-capable only through an owned, ready multi-work binding. Prompt
|
||||
admission and model-context guards reject unbound or relocated coordinate children.
|
||||
Multi-work cannot be a child, and the existing workers remain leaves.
|
||||
|
||||
Normal delegation cards and child navigation recognize the retained `subagent`
|
||||
|
||||
+2
-2
@@ -320,7 +320,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
||||
if (agent === "multi-work") throw new Error("multi-work is primary only");
|
||||
if (agent === "coordinate" && event.agent !== "multi-work")
|
||||
throw new Error(
|
||||
"coordinate cannot run as a subagent without a ready worktree binding",
|
||||
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||
);
|
||||
if (workerNames.some((name) => name === event.agent))
|
||||
throw new Error("Workers cannot delegate tasks");
|
||||
@@ -349,7 +349,7 @@ const makeBasePlugin = (permissionsChanged: (permissions: Agent.Info["permission
|
||||
parent.location.directory !== binding.data.root.directory
|
||||
)
|
||||
throw new Error(
|
||||
"coordinate cannot run as a subagent without a ready worktree binding",
|
||||
"coordinate cannot run as a subagent without a ready multi-work binding",
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
+65
-29
@@ -9,7 +9,8 @@ import { randomUUID } from "node:crypto";
|
||||
import { Herdr, rootSchema } from "./herdr.js";
|
||||
import { multiWorkGuide } from "./routing.js";
|
||||
|
||||
export const bindingSchema = z.object({
|
||||
export const bindingSchema = z
|
||||
.object({
|
||||
owner: z.string(),
|
||||
child: z.string(),
|
||||
projectID: z.string(),
|
||||
@@ -18,7 +19,14 @@ export const bindingSchema = z.object({
|
||||
workspaceID: z.string(),
|
||||
branch: z.string(),
|
||||
ready: z.boolean(),
|
||||
});
|
||||
})
|
||||
.refine(
|
||||
(binding) =>
|
||||
binding.directory === binding.root.directory
|
||||
? binding.workspaceID === binding.root.workspaceID && binding.branch === "root"
|
||||
: binding.workspaceID !== binding.root.workspaceID,
|
||||
{ message: "Invalid multi-work placement metadata" },
|
||||
);
|
||||
export const bindingKey = (id: string) => `multi-work:${id}`;
|
||||
const inputSchema = z
|
||||
.object({
|
||||
@@ -98,7 +106,7 @@ export const installMultiWork = (
|
||||
agent.name = Agent.Name.make("multi-work");
|
||||
agent.mode = "primary";
|
||||
agent.description =
|
||||
"Coordinate independent tasks in Herdr worktrees; retrieve local facts with fast-lo.";
|
||||
"Coordinate independent tasks locally or in Herdr worktrees; retrieve local facts with fast-lo.";
|
||||
agent.system = multiWorkGuide;
|
||||
agent.permissions = [
|
||||
...coordinate.permissions.filter((rule) => rule.action !== "subagent"),
|
||||
@@ -137,14 +145,14 @@ export const installMultiWork = (
|
||||
child.projectID !== binding.projectID ||
|
||||
child.location.directory !== binding.directory
|
||||
)
|
||||
throw new Error("Coordinate child does not match its ready worktree binding");
|
||||
throw new Error("Coordinate child does not match its ready multi-work binding");
|
||||
const parent = yield* context.session.get({ sessionID: child.parentID });
|
||||
if (
|
||||
parent.agent !== "multi-work" ||
|
||||
parent.projectID !== binding.projectID ||
|
||||
parent.location.directory !== binding.root.directory
|
||||
)
|
||||
throw new Error("Worktree owner changed agent or location");
|
||||
throw new Error("Binding owner changed agent or location");
|
||||
return binding;
|
||||
});
|
||||
|
||||
@@ -184,7 +192,7 @@ export const installMultiWork = (
|
||||
agent: { type: "string", enum: ["fast-lo", "coordinate"] },
|
||||
};
|
||||
tool.description =
|
||||
"Delegate to fast-lo locally, or coordinate in a new Herdr worktree using worktree: {base, branch?, label?}. Prefer descriptive human-readable branch and label values, for example {base:'main',branch:'fix/login-timeout',label:'Fix login timeout'}. Continue an owned coordinate child with sessionID and no worktree argument; naming fields are create-only. Native background and results apply.";
|
||||
"Delegate to fast-lo for retrieval, or coordinate in this root checkout by omitting worktree. To isolate new coordinate work, use worktree: {base, branch?, label?}. Continue an owned coordinate child with sessionID and no worktree argument. Native background and results apply.";
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
);
|
||||
@@ -208,9 +216,7 @@ export const installMultiWork = (
|
||||
throw new Error("A bound coordinate child cannot be repurposed");
|
||||
if (call.agent !== "multi-work") {
|
||||
if (input.worktree || input.agent === "coordinate")
|
||||
throw new Error(
|
||||
"Only multi-work may initialize coordinate worktrees",
|
||||
);
|
||||
throw new Error("Only multi-work may delegate to coordinate");
|
||||
return yield* native.execute(input, call);
|
||||
}
|
||||
const parent = yield* context.session.get({ sessionID: call.sessionID });
|
||||
@@ -240,14 +246,27 @@ export const installMultiWork = (
|
||||
}
|
||||
if (input.agent !== "coordinate")
|
||||
throw new Error("multi-work delegates only to fast-lo or coordinate");
|
||||
if (
|
||||
input.sessionID
|
||||
? input.worktree !== undefined
|
||||
: input.worktree === undefined
|
||||
)
|
||||
if (input.sessionID && input.worktree !== undefined)
|
||||
throw new Error(
|
||||
"New coordinate work requires worktree: {base}; resumes require only sessionID",
|
||||
"Coordinate resumes require only sessionID; worktree is create-only",
|
||||
);
|
||||
const lock = input.sessionID ?? `${call.sessionID}:${call.id}`;
|
||||
if (busy.has(lock)) throw new Error("This delegation is already active");
|
||||
busy.add(lock);
|
||||
let childLock: string | undefined;
|
||||
return yield* Effect.gen(function* () {
|
||||
let initialized = false;
|
||||
let placement: z.infer<typeof bindingSchema> | undefined;
|
||||
if (input.sessionID) {
|
||||
const binding = yield* validate(Session.ID.make(input.sessionID));
|
||||
if (binding.owner !== call.sessionID)
|
||||
throw new Error("Cannot resume another parent's binding");
|
||||
placement = binding;
|
||||
}
|
||||
if (
|
||||
input.worktree ||
|
||||
(placement && placement.directory !== placement.root.directory)
|
||||
) {
|
||||
const { data: agent } = yield* context.agent.get({
|
||||
agentID: call.agent,
|
||||
location: { directory: parent.location.directory },
|
||||
@@ -264,17 +283,16 @@ export const installMultiWork = (
|
||||
throw new Error(
|
||||
`herdr_worktree requires explicit allow; custom approval is unavailable (${permission ?? "ask"})`,
|
||||
);
|
||||
const lock = input.sessionID ?? `${call.sessionID}:${call.id}`;
|
||||
if (busy.has(lock)) throw new Error("This delegation is already active");
|
||||
busy.add(lock);
|
||||
let childLock: string | undefined;
|
||||
return yield* Effect.gen(function* () {
|
||||
let initialized = false;
|
||||
let placement: z.infer<typeof bindingSchema> | undefined;
|
||||
if (input.sessionID) {
|
||||
const binding = yield* validate(Session.ID.make(input.sessionID));
|
||||
if (binding.owner !== call.sessionID)
|
||||
throw new Error("Cannot resume another parent's worktree");
|
||||
}
|
||||
if (placement) {
|
||||
const binding = placement;
|
||||
if (
|
||||
root.directory !== binding.root.directory ||
|
||||
root.workspaceID !== binding.root.workspaceID ||
|
||||
root.repo !== binding.root.repo
|
||||
)
|
||||
throw new Error("Bound Herdr root is no longer available");
|
||||
if (binding.directory !== binding.root.directory)
|
||||
yield* Effect.tryPromise((signal) =>
|
||||
herdr.verify(
|
||||
binding.directory,
|
||||
@@ -284,7 +302,6 @@ export const installMultiWork = (
|
||||
signal,
|
||||
),
|
||||
);
|
||||
placement = binding;
|
||||
initialized = true;
|
||||
}
|
||||
const { worktree, ...args } = input;
|
||||
@@ -310,8 +327,6 @@ export const installMultiWork = (
|
||||
);
|
||||
childLock = id;
|
||||
busy.add(id);
|
||||
if (!worktree)
|
||||
throw new Error("Missing worktree initialization");
|
||||
const sessionID = Session.ID.make(id);
|
||||
const child = yield* context.session.get({ sessionID });
|
||||
if (
|
||||
@@ -323,6 +338,27 @@ export const installMultiWork = (
|
||||
throw new Error(
|
||||
"Native executor returned an unexpected child",
|
||||
);
|
||||
if (!worktree) {
|
||||
const binding = {
|
||||
owner: call.sessionID,
|
||||
child: id,
|
||||
projectID: parent.projectID,
|
||||
root,
|
||||
directory: root.directory,
|
||||
workspaceID: root.workspaceID,
|
||||
branch: "root",
|
||||
ready: true,
|
||||
};
|
||||
yield* context.storage.set(callKey, {
|
||||
child: id,
|
||||
branch: binding.branch,
|
||||
root,
|
||||
});
|
||||
yield* context.storage.set(bindingKey(id), binding);
|
||||
placement = binding;
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
const branch =
|
||||
worktree.branch ?? `multi-work/${randomUUID()}`;
|
||||
yield* context.storage.set(callKey, {
|
||||
|
||||
+6
-4
@@ -16,15 +16,17 @@ export const descriptions = {
|
||||
export const multiWorkGuide = `You are multi-work, a primary orchestrator in a verified Herdr root checkout.
|
||||
Read, plan, track todos and delegate; never implement or execute shell commands yourself.
|
||||
Use native subagent with agent fast-lo for focused local retrieval. Use agent coordinate
|
||||
for work in a new Herdr worktree, supplying descriptive human-readable names such as
|
||||
without worktree to edit concurrently in this root checkout, or isolate work in a new
|
||||
Herdr worktree by supplying descriptive human-readable names such as
|
||||
worktree: {base: "main", branch: "fix/login-timeout", label: "Fix login timeout"}, plus
|
||||
description and a complete prompt with scope, constraints and acceptance checks. The tool
|
||||
uses the requested branch and Herdr workspace label; when omitted, it creates the prior unique
|
||||
uses the requested branch and Herdr workspace label; when the branch is omitted, it creates the prior unique
|
||||
multi-work/<uuid> branch and lets Herdr choose its default label. It moves the coordinate child
|
||||
before its task begins, and preserves your root session. Dirty root changes are not copied.
|
||||
Resume an owned coordinate child using its sessionID and no worktree argument. Never switch
|
||||
its agent or location; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves.
|
||||
Parallelize independent work with disjoint ownership; serialize dependencies. Native background
|
||||
its agent or assigned checkout; naming fields are create-only. Only fast-lo and coordinate are permitted. Workers are leaves.
|
||||
Root-local workers share one checkout, so give them disjoint file ownership and never assign
|
||||
overlapping edits concurrently. Parallelize independent work; serialize dependencies. Native background
|
||||
delegation returns immediately and notifies you when complete; do not poll or duplicate work.
|
||||
Reconcile results and report checkout, branch and child IDs. Retained resources on failure
|
||||
must not be deleted automatically. Never retry ambiguous creation failures blindly.
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export default Plugin.define({
|
||||
return (
|
||||
<Show when={status()}>
|
||||
<Show when={props.centered} fallback={<Content />}>
|
||||
<box width="100%" alignItems="center">
|
||||
<box position="absolute" top={2} left={0} width="100%" alignItems="center">
|
||||
<Content />
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
+151
-1
@@ -14,6 +14,8 @@ import { bindingKey, bindingSchema, installMultiWork } from "../src/multi-work.j
|
||||
|
||||
class FixtureHerdr extends Herdr {
|
||||
eligible = true;
|
||||
rootWorkspaceID = "w1";
|
||||
rootRepo = "/root/.git";
|
||||
creations = 0;
|
||||
verified = 0;
|
||||
delayCreation = false;
|
||||
@@ -24,7 +26,7 @@ class FixtureHerdr extends Herdr {
|
||||
creationStarted = Promise.withResolvers<void>();
|
||||
override async root(directory: string) {
|
||||
return this.eligible && directory === "/root"
|
||||
? { directory, workspaceID: "w1", repo: "/root/.git" }
|
||||
? { directory, workspaceID: this.rootWorkspaceID, repo: this.rootRepo }
|
||||
: undefined;
|
||||
}
|
||||
override async create(
|
||||
@@ -366,6 +368,128 @@ test("local fast-lo uses native delegation without a worktree", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("coordinate can create and resume a root-local background child without Herdr creation or movement", async () => {
|
||||
const f = fixture();
|
||||
f.parent.permissions = [{ action: "herdr_worktree", resource: "*", effect: "deny" }];
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
const created = yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "edit assigned files",
|
||||
background: true,
|
||||
});
|
||||
expect(created.metadata).toMatchObject({
|
||||
sessionID: "ses_child1",
|
||||
status: "running",
|
||||
directory: "/root",
|
||||
workspaceID: "w1",
|
||||
branch: "root",
|
||||
});
|
||||
expect(f.herdr.creations).toBe(0);
|
||||
expect(f.order).toEqual(["create", "prompt:/root"]);
|
||||
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1")))).toMatchObject({
|
||||
owner: "ses_parent",
|
||||
directory: "/root",
|
||||
workspaceID: "w1",
|
||||
branch: "root",
|
||||
ready: true,
|
||||
});
|
||||
yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume local",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
});
|
||||
expect(f.herdr.creations).toBe(0);
|
||||
expect(f.herdr.verified).toBe(0);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("root-local resume rejects location, binding, and live-root changes", async () => {
|
||||
for (const tamper of ["session", "workspace", "root", "branch", "live-root"] as const) {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "work",
|
||||
});
|
||||
if (tamper === "session")
|
||||
f.sessions.get("ses_child1")!.location.directory = "/other";
|
||||
else if (tamper === "live-root") f.herdr.rootWorkspaceID = "foreign";
|
||||
else {
|
||||
const binding = bindingSchema.parse(
|
||||
f.storage.get(bindingKey("ses_child1")),
|
||||
);
|
||||
const changed = { ...binding };
|
||||
if (tamper === "workspace") changed.workspaceID = "foreign";
|
||||
if (tamper === "root") {
|
||||
changed.root = { ...binding.root, workspaceID: "foreign" };
|
||||
changed.workspaceID = "foreign";
|
||||
}
|
||||
if (tamper === "branch") changed.branch = "main";
|
||||
f.storage.set(bindingKey("ses_child1"), changed);
|
||||
}
|
||||
expect(
|
||||
(yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
}),
|
||||
))._tag,
|
||||
).toBe("Failure");
|
||||
expect(f.prompts).toBe(1);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("interrupted root-local creation retains its binding and cannot be blindly replayed", async () => {
|
||||
const f = fixture();
|
||||
f.hold();
|
||||
const controller = new AbortController();
|
||||
const running = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
return yield* f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "work",
|
||||
});
|
||||
}),
|
||||
),
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await f.entered.promise;
|
||||
controller.abort();
|
||||
await expect(running).rejects.toBeDefined();
|
||||
expect(bindingSchema.parse(f.storage.get(bindingKey("ses_child1"))).ready).toBe(true);
|
||||
expect(f.storage.has(`multi-work-call:${f.call.sessionID}:${f.call.id}`)).toBe(true);
|
||||
const replay = await Effect.runPromise(
|
||||
Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "local edit",
|
||||
prompt: "work",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(replay._tag).toBe("Failure");
|
||||
expect(f.prompts).toBe(1);
|
||||
});
|
||||
|
||||
test("custom branch and workspace label pass through delegation", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
@@ -623,6 +747,32 @@ test("configured build restrictions remain effective for worktree preparation",
|
||||
);
|
||||
});
|
||||
|
||||
test("worktree resumes retain the herdr_worktree permission guard", async () => {
|
||||
const f = fixture();
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* f.install;
|
||||
yield* f.invoke();
|
||||
f.parent.permissions = [
|
||||
{ action: "herdr_worktree", resource: "*", effect: "deny" },
|
||||
];
|
||||
const resumed = yield* Effect.exit(
|
||||
f.execute({
|
||||
agent: "coordinate",
|
||||
description: "resume",
|
||||
prompt: "continue",
|
||||
sessionID: "ses_child1",
|
||||
}),
|
||||
);
|
||||
expect(resumed._tag).toBe("Failure");
|
||||
expect(f.herdr.verified).toBe(0);
|
||||
expect(f.prompts).toBe(1);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("failed initialization records the attempt and never blindly recreates on replay", async () => {
|
||||
const f = fixture();
|
||||
f.missingDestination();
|
||||
|
||||
+13
-10
@@ -306,17 +306,17 @@ async function host(
|
||||
child = target;
|
||||
for (const hook of sessionHooks.prompt ?? []) await hook({ sessionID: "child" });
|
||||
},
|
||||
bindCoordinate(ready: boolean, moved = true) {
|
||||
childDirectory = moved ? `${directory}/worktree` : directory;
|
||||
bindCoordinate(ready: boolean, moved = true, local = false) {
|
||||
childDirectory = local ? directory : moved ? `${directory}/worktree` : directory;
|
||||
storage.set(bindingKey("child"), {
|
||||
owner: "parent",
|
||||
child: "child",
|
||||
projectID: "project-fixture",
|
||||
ready,
|
||||
root: { directory, workspaceID: "w1", repo: `${directory}/.git` },
|
||||
directory: `${directory}/worktree`,
|
||||
workspaceID: "w2",
|
||||
branch: "multi-work/test",
|
||||
directory: local ? directory : `${directory}/worktree`,
|
||||
workspaceID: local ? "w1" : "w2",
|
||||
branch: local ? "root" : "multi-work/test",
|
||||
});
|
||||
},
|
||||
async tools(caller: string) {
|
||||
@@ -663,22 +663,25 @@ test("manual calls and command-created child sessions cannot bypass delegation r
|
||||
expect((await result.tools("build")).subagent?.description).toBe("Native subagents");
|
||||
});
|
||||
|
||||
test("coordinate prompt admission requires both owned provenance and a completed move; multi-work cannot be a child", async () => {
|
||||
test("coordinate prompt admission requires owned ready placement; local and moved bindings work", async () => {
|
||||
const result = await host();
|
||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||
"ready worktree binding",
|
||||
"ready multi-work binding",
|
||||
);
|
||||
result.bindCoordinate(false);
|
||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||
"ready worktree binding",
|
||||
"ready multi-work binding",
|
||||
);
|
||||
result.bindCoordinate(true, false);
|
||||
await expect(result.command("multi-work", "coordinate")).rejects.toThrow(
|
||||
"ready worktree binding",
|
||||
"ready multi-work binding",
|
||||
);
|
||||
result.bindCoordinate(true);
|
||||
await result.command("multi-work", "coordinate");
|
||||
await expect(result.command("build", "coordinate")).rejects.toThrow("ready worktree binding");
|
||||
result.bindCoordinate(true, true, true);
|
||||
await result.command("multi-work", "coordinate");
|
||||
await expect(result.command("build", "coordinate")).rejects.toThrow("ready multi-work binding");
|
||||
result.bindCoordinate(true);
|
||||
await expect(result.command("multi-work", "fast-lo")).rejects.toThrow("local fast-lo");
|
||||
for (const invoke of [result.delegate, result.command])
|
||||
await expect(invoke("build", "multi-work")).rejects.toThrow("primary only");
|
||||
|
||||
+74
-3
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test";
|
||||
import type { Plugin } from "@opencode/plugin/tui";
|
||||
import type { Route, SlotMap } from "@opencode/plugin/tui/context";
|
||||
import { testRender, type JSX } from "@opentui/solid";
|
||||
import { jsx } from "@opentui/solid/jsx-runtime";
|
||||
import { createSignal } from "solid-js";
|
||||
import { build } from "../scripts/build.js";
|
||||
import type { QuotaStatus } from "../src/quota.js";
|
||||
@@ -87,12 +88,19 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
||||
const renderHome = homeRender;
|
||||
const home = await testRender(() => renderHome({ mode: "normal", showDetails: false }), {
|
||||
width: 40,
|
||||
height: 5,
|
||||
height: 6,
|
||||
});
|
||||
await home.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
||||
const homeFrame = home.captureCharFrame();
|
||||
expect(homeFrame).toContain(" ChatGPT quota (home)");
|
||||
expect(homeFrame).toContain(" 5h: 90% · due");
|
||||
expect(homeFrame.split("\n")).toEqual([
|
||||
" ",
|
||||
" ",
|
||||
" ChatGPT quota (home) ",
|
||||
" 5h: 90% · due ",
|
||||
" ",
|
||||
" ",
|
||||
"",
|
||||
]);
|
||||
expect(statusCalls).toBe(1);
|
||||
home.renderer.destroy();
|
||||
|
||||
@@ -152,3 +160,66 @@ test("built quota slot stays reactive across pending, empty, failure, update, an
|
||||
if (cleanup) await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("home quota stays out of the host footer row at wide and narrow widths", async () => {
|
||||
await build();
|
||||
const { default: plugin } = await import(new URL("../dist/tui.js", import.meta.url).href);
|
||||
let homeRender: HomeSlotRender | undefined;
|
||||
const contextFixture = {
|
||||
client: {
|
||||
rpc: () => ({
|
||||
status: async () => status("home", 90),
|
||||
events: { on: () => () => undefined },
|
||||
}),
|
||||
},
|
||||
ui: {
|
||||
slot(claim: { after?: "prompt.footer"; render: HomeSlotRender }) {
|
||||
if (claim.after === "prompt.footer") homeRender = claim.render;
|
||||
return () => undefined;
|
||||
},
|
||||
router: { current: () => ({ type: "home" as const }) },
|
||||
},
|
||||
theme: { text: { default: "#ffffff", muted: "#888888" } },
|
||||
};
|
||||
// SAFETY: This focused fixture implements every context member read by slot setup/rendering.
|
||||
const cleanup = await plugin.setup(Object.assign({} as Plugin.Context, contextFixture));
|
||||
if (!homeRender) throw new Error("Plugin did not register the home slot");
|
||||
const renderHome = homeRender;
|
||||
try {
|
||||
for (const width of [75, 40]) {
|
||||
const footer = (quota: boolean) =>
|
||||
jsx("box", {
|
||||
flexDirection: "row",
|
||||
children: [
|
||||
jsx("text", { children: "/project" }),
|
||||
jsx("box", { flexGrow: 1 }),
|
||||
jsx("text", { children: "NORMAL" }),
|
||||
jsx("text", { children: " ctrl+k commands" }),
|
||||
quota ? renderHome({ mode: "normal", showDetails: false }) : undefined,
|
||||
],
|
||||
});
|
||||
const baseline = await testRender(() => footer(false), { width, height: 6 });
|
||||
const rendered = await testRender(() => footer(true), { width, height: 6 });
|
||||
try {
|
||||
await baseline.renderOnce();
|
||||
await rendered.waitForFrame((frame) => frame.includes("ChatGPT quota (home)"));
|
||||
const baselineRows = baseline.captureCharFrame().split("\n");
|
||||
const quotaRows = rendered.captureCharFrame().split("\n");
|
||||
expect(quotaRows[0]).toBe(baselineRows[0]);
|
||||
expect(quotaRows[0]).toContain("/project");
|
||||
expect(quotaRows[0]).toContain("NORMAL ctrl+k commands");
|
||||
expect(quotaRows[1]?.trim()).toBe("");
|
||||
expect(quotaRows[2]?.trim()).toBe("ChatGPT quota (home)");
|
||||
expect(quotaRows[3]?.trim()).toBe("5h: 90% · due");
|
||||
expect(quotaRows[2]?.indexOf("ChatGPT quota (home)")).toBe(
|
||||
Math.ceil((width - "ChatGPT quota (home)".length) / 2),
|
||||
);
|
||||
} finally {
|
||||
baseline.renderer.destroy();
|
||||
rendered.renderer.destroy();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (cleanup) await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user