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
2 changes: 2 additions & 0 deletions doc/plugins/PLUGIN_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,8 @@ Minimum event set:
- `issue.checked_out`
- `issue.released`
- `issue.assignment_wakeup_requested`
- `issue.interaction.created`
- `issue.interaction.resolved`
- `agent.created`
- `agent.updated`
- `agent.status_changed`
Expand Down
24 changes: 22 additions & 2 deletions packages/plugins/paperclip-plugin-telegram-notify/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ cached or logged), the mirror's failure discipline (every handler wrapped, failu
| `agent.run.failed` | **Run failed** — agent, run id, truncated error | A failed run still spends the day's cap, so this means today is over, not that it will retry |
| `budget.incident.opened` / `.resolved` | **Budget stopped work** / resolved | Money stopped, or restarted |
| `issue.updated` → `blocked` | **Waiting for you** — task key and the unblock action | A task became somebody's to decide |
| `issue.interaction.created` | **Waiting for you** — task key and what kind of answer is wanted | An agent asked a person directly and stopped |

The spec allowed three and said a fourth needs an argument. The argument for the fourth: every
loop in this kit that produces work for the owner terminates in a parked task — the completion
Expand All @@ -43,6 +44,23 @@ The message leads with the `unblockDescriptor.action` when there is one. "Merge
is something you can act on from a phone; "blocked" is not. When the unblock owner is a named
user it says **Waiting for you**; when it is `board` — meaning anyone — it says **Task parked**.

**Watching the board state alone was still not enough.** An agent that wants a person to decide
something cannot park the task and name that person as the unblock owner: the board answers
`403 Agents may only name themselves as an unblock owner`. What it does instead is open an
issue-thread interaction — a confirmation, a question, a list of verdicts — and stop. The task's
status never moves, so the transition above never fires, and that hand-off reached no surface at
all ([kit #13](https://github.com/elysosss/agent-company-kit/issues/13)). `issue.interaction.created`
is the second path to the same message. Only interactions with no `addresseeAgentId` count: one
addressed to an agent is answered by the agent loop and is nobody's business on a phone. The
message names the interaction *kind*, not the question — the question stays on the board.

**One hand-off sends one message.** A task can produce both signals — an agent opens an
interaction, then somebody parks the task because of it — and they are the same event. Both paths
claim a single per-issue slot in plugin state before sending, and only the signal that claimed it
releases it: the board path when the task leaves `blocked`, the interaction path on
`issue.interaction.resolved`. A partial verdict submission resolves some items and leaves the
interaction pending; the slot stays held, because nobody is off the hook yet.

**The escalation event is plugin-to-plugin, and its name is namespaced**:
`plugin.paperclip-plugin-escalation.escalation-raised`. The emitter passes the bare name; every
subscriber must use the prefixed one. Subscribing to the bare name compiles, runs, and silently
Expand Down Expand Up @@ -105,12 +123,14 @@ for ever — that is the mirror's open bug ([kit #11](https://github.com/elysoss
and there was no reason to reproduce it here.

Replay is handled for the parked-task message: the last seen status per issue is kept in plugin
state, so a re-delivered `issue.updated` does not buzz the phone twice.
state, so a re-delivered `issue.updated` does not buzz the phone twice. The same state holds the
hand-off slot described above, which also makes a re-delivered `issue.interaction.created`
harmless.

## Tests

```bash
pnpm test # 32, offline — no board, no network, fetch stubbed
pnpm test # 48, offline — no board, no network, fetch stubbed
pnpm typecheck
```

Expand Down
11 changes: 11 additions & 0 deletions packages/plugins/paperclip-plugin-telegram-notify/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export const ESCALATION_EVENT = "plugin.paperclip-plugin-escalation.escalation-r
export const STATE_KEYS = {
/** The status this issue was in when we last looked. */
lastStatus: "telegram:last-status",
/**
* The hand-off we last said "waiting for you" about, or absent when the issue
* is not currently waiting on anyone as far as we told the operator.
*
* Two different signals mean the same hand-off — the task parking as
* `blocked`, and an agent opening an issue-thread interaction — and a task can
* produce both. One key shared by both paths is what makes a hand-off buzz the
* phone once. The value says which signal claimed it, so only that signal's
* ending clears it: `status:blocked`, or `interaction:<id>`.
*/
waitingNotified: "telegram:waiting-notified",
} as const;

/**
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/paperclip-plugin-telegram-notify/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,31 @@ export function formatWaitingForHuman(input: {
lines.push(input.action ? escapeHtml(clip(input.action, 200)) : "No unblock action was recorded.");
return withLink(lines, input.link);
}

/**
* The other half of the same hand-off. An agent that wants a person to decide
* something cannot park the task and name that person as the unblock owner —
* the board refuses an agent naming anyone but itself — so it opens an
* issue-thread interaction instead and stops. That is the ending of most
* maintenance loops, and until this message it produced no notification at all.
*
* The kind is an enum, not the question. What was actually asked stays on the
* board, same rule as everywhere else in this file.
*/
const INTERACTION_ASKS: Record<string, string> = {
request_confirmation: "An agent is waiting for you to confirm something.",
request_checkbox_confirmation: "An agent is waiting for you to confirm something.",
ask_user_questions: "An agent asked you a question.",
request_item_verdicts: "An agent is waiting for your verdict on a list of items.",
suggest_tasks: "An agent suggested tasks for you to accept or reject.",
};

export function formatWaitingForInteraction(input: {
issue: IssueRef | null;
issueId: string | null;
kind: string | null;
link: string | null;
}): string {
const ask = (input.kind ? INTERACTION_ASKS[input.kind] : null) ?? "An agent is waiting on you.";
return withLink(["<b>Waiting for you</b>", issueLabel(input.issue, input.issueId), ask], input.link);
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const manifest: PaperclipPluginManifestV1 = {
type: "boolean",
title: "Notify when a task is parked for you",
description:
"The completion check parking an abandoned task, or a reviewer that will not merge. This is where every loop that produces work for you ends.",
"The completion check parking an abandoned task, a reviewer that will not merge, or an agent opening a question or confirmation for you. This is where every loop that produces work for you ends. One hand-off sends one message however it reaches us.",
default: true,
},
},
Expand Down
99 changes: 98 additions & 1 deletion packages/plugins/paperclip-plugin-telegram-notify/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
formatEscalation,
formatRunFailure,
formatWaitingForHuman,
formatWaitingForInteraction,
type IssueRef,
} from "./format.js";
import { TelegramApiError, TelegramClient } from "./telegram.js";
Expand Down Expand Up @@ -75,8 +76,36 @@ function num(raw: Record<string, unknown>, key: string): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

/** The token the board-status path claims a hand-off with. */
const STATUS_HANDOFF = "status:blocked";

const plugin = definePlugin({
async setup(ctx) {
const waitingKey = (issueId: string) => ({
scopeKind: "issue" as const,
scopeId: issueId,
stateKey: STATE_KEYS.waitingNotified,
});

/**
* Takes the issue's single "already told them" slot, or reports that some
* other signal holds it. Both hand-off paths go through here, which is what
* stops one hand-off — an interaction opened, and the task parked because of
* it — from buzzing the phone twice.
*/
const claimHandoff = async (issueId: string, token: string): Promise<boolean> => {
const held = await ctx.state.get(waitingKey(issueId));
if (typeof held === "string" && held.length > 0) return false;
await ctx.state.set(waitingKey(issueId), token);
return true;
};

/** Releases the slot, but only for the signal that took it. */
const releaseHandoff = async (issueId: string, token: string): Promise<void> => {
if ((await ctx.state.get(waitingKey(issueId))) !== token) return;
await ctx.state.delete(waitingKey(issueId));
};

/**
* Sends to every allowlisted chat. One failing recipient must not silence
* the others, so each send is guarded on its own — the alternative loses
Expand Down Expand Up @@ -251,7 +280,15 @@ const plugin = definePlugin({
const seen = await ctx.state.get({ ...scope, stateKey: STATE_KEYS.lastStatus });
const previous = typeof seen === "string" ? seen : null;
await ctx.state.set({ ...scope, stateKey: STATE_KEYS.lastStatus }, issue.status);
if (issue.status !== WAITING_STATUS || previous === WAITING_STATUS) return;
if (issue.status !== WAITING_STATUS) {
// Off the parked status is where this path's hand-off ends.
await releaseHandoff(issueId, STATUS_HANDOFF);
return;
}
if (previous === WAITING_STATUS) return;
// An interaction on this task already said "waiting for you" — the
// parking is the same hand-off arriving by its other signal.
if (!(await claimHandoff(issueId, STATUS_HANDOFF))) return;

const descriptor = issue.unblockDescriptor as
| { owner?: unknown; action?: unknown }
Expand Down Expand Up @@ -282,6 +319,66 @@ const plugin = definePlugin({
}),
);

/**
* The hand-off the board state cannot show. An agent that needs a person to
* decide something is not allowed to park the task and name that person as
* the unblock owner — naming anyone but itself is a 403 — so it opens an
* issue-thread interaction and stops. The task stays in its current status,
* so the `issue.updated` path above never fires, and before this handler the
* terminal state of a maintenance loop reached no surface at all.
*/
ctx.events.on("issue.interaction.created", (event) =>
guard(ctx, "issue.interaction.created", async () => {
const issueId = event.entityId;
if (!issueId) return;
const config = await readConfig(ctx, event.companyId);
if (!config?.notify.waitingForHuman) return;

const raw = payloadOf(event);
// Addressed to an agent means the agent loop answers it; a phone has
// nothing to do with it. Only an unaddressed pending interaction is a
// hand-off to a human.
if (str(raw, "addresseeAgentId")) return;
if (str(raw, "interactionStatus") !== "pending") return;
const interactionId = str(raw, "interactionId");
if (!interactionId) return;
if (!(await claimHandoff(issueId, `interaction:${interactionId}`))) return;

const issue = await lookupIssue(ctx, issueId, event.companyId);
await broadcast(
config,
event.companyId,
"waiting-for-human",
formatWaitingForInteraction({
issue,
issueId,
kind: str(raw, "interactionKind"),
link: boardLink(config.boardBaseUrl, issue ?? { id: issueId, key: null, title: null }),
}),
issueId,
);
}),
);

/**
* Answered, rejected, withdrawn, expired — the wait is over, so the slot is
* free for the next hand-off on this task. Deliberately not gated on the
* config toggle: state has to stay honest even while messages are switched
* off, or turning them back on finds a claim nothing will ever release.
*/
ctx.events.on("issue.interaction.resolved", (event) =>
guard(ctx, "issue.interaction.resolved", async () => {
const issueId = event.entityId;
if (!issueId) return;
const raw = payloadOf(event);
const interactionId = str(raw, "interactionId");
// A partial verdict submission resolves some items and leaves the
// interaction pending. Nobody is off the hook yet.
if (!interactionId || str(raw, "interactionStatus") === "pending") return;
await releaseHandoff(issueId, `interaction:${interactionId}`);
}),
);

ctx.logger.info("Telegram notify ready");
},

Expand Down
Loading
Loading