Skip to content
This repository was archived by the owner on Jul 20, 2026. It is now read-only.
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: 1 addition & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "workstacean-dashboard",
"version": "0.7.12",
"version": "0.7.13",
"private": true,
"type": "module",
"scripts": {
Expand Down
13 changes: 13 additions & 0 deletions lib/plugins/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ export class GitHubPlugin implements Plugin {

const content = String((msg.payload as Record<string, unknown>).content ?? "").trim();
if (!content) return;
if (/^Skill ".+" completed by \w+$/i.test(content)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Regex won't match agent names containing hyphens.

The pattern \w+ only matches [a-zA-Z0-9_]. If this.config.name contains a hyphen (e.g., "proto-agent"), the regex fails and the "echo" comment is posted anyway.

🐛 Proposed fix
-      if (/^Skill ".+" completed by \w+$/i.test(content)) return;
+      if (/^Skill ".+" completed by [\w-]+$/i.test(content)) return;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (/^Skill ".+" completed by \w+$/i.test(content)) return;
if (/^Skill ".+" completed by [\w-]+$/i.test(content)) return;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/plugins/github.ts` at line 331, The if-statement that tests content using
the regex matching "Skill ... completed by ..." currently uses \w+ for the agent
name and therefore rejects names with hyphens; update that regex on the line
containing the /^Skill ".+" completed by \w+$/i.test(content) check so the final
token allows hyphens (for example replace the \w+ token with a character class
that permits word characters and hyphens or with a non-space token) so agent
names like "proto-agent" are matched and the early return works correctly.


await this._postComment(getToken, pending, content);
});
Expand Down Expand Up @@ -552,6 +553,10 @@ export class GitHubPlugin implements Plugin {
console.log(`[github] ${event} on ${ctx.owner}/${ctx.repo}#${ctx.number} → ${skillHint ?? "default"}`);
}

/** Tracks recently-dispatched (owner/repo#number) to suppress duplicate webhook deliveries. */
private recentDispatches = new Map<string, number>();
private static readonly DEDUP_WINDOW_MS = 60_000;

private _handleAutoTriage(
event: string,
payload: Record<string, unknown>,
Expand All @@ -560,6 +565,14 @@ export class GitHubPlugin implements Plugin {
bus: EventBus,
getToken: (owner: string, repo: string) => Promise<string>,
): void {
const dedupKey = `${ctx.owner}/${ctx.repo}#${ctx.number}`;
const lastDispatched = this.recentDispatches.get(dedupKey);
if (lastDispatched && Date.now() - lastDispatched < GitHubPlugin.DEDUP_WINDOW_MS) {
console.log(`[github] Auto-triage: skipping duplicate ${dedupKey} (dispatched ${Date.now() - lastDispatched}ms ago)`);
return;
}
this.recentDispatches.set(dedupKey, Date.now());

(async () => {
try {
const token = await getToken(ctx.owner, ctx.repo);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "workstacean",
"version": "0.7.12",
"version": "0.7.13",
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
Expand Down
15 changes: 15 additions & 0 deletions src/executor/executors/a2a-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ export class A2AExecutor implements IExecutor {
return this.config.name;
}

/** Expose the configured agent URL so the dispatcher can pick an appropriate callback base URL. */
get url(): string {
return this.config.url;
}

/**
* Update the streaming + pushNotifications capability flags from the agent
* card. Called by SkillBrokerPlugin after every card discovery pass so the
Expand Down Expand Up @@ -458,6 +463,16 @@ export class A2AExecutor implements IExecutor {
const delta = this._worldStateDeltaFromParts(artifact.parts);
if (delta) streamDeltaData[WORLDSTATE_DELTA_MIME_TYPE] = delta;
}
// Non-terminal state (submitted / working / input-required) on the
// initial task event → stop consuming the SSE stream and let
// TaskTracker drive the task to completion via tasks/get polling.
// Holding the stream open longer forces slow agents to keep their
// SSE generator alive for minutes, which crashes them when the
// sync caller eventually times out and closes the connection.
if (taskState !== "completed" && taskState !== "failed"
&& taskState !== "canceled" && taskState !== "rejected") {
break;
}
Comment on lines +466 to +475

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Non-terminal early break can drop input-required task handling.

At Line [466]-[475], the stream now breaks on all non-terminal task states. But the dispatcher currently tracks only submitted/working (src/executor/skill-dispatcher-plugin.ts, Line [28] and Line [319]). If the first state is input-required (or another non-terminal), it can bypass tracking/polling and be treated as completed too early.

Suggested alignment fix (dispatcher side)
-const WORKING_STATES = new Set(["submitted", "working"]);
+const NON_TERMINAL_STATES = new Set(["submitted", "working", "input-required"]);
...
-        && WORKING_STATES.has(taskState)
+        && NON_TERMINAL_STATES.has(taskState)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/executor/executors/a2a-executor.ts` around lines 466 - 475, The early
break in src/executor/executors/a2a-executor.ts stops SSE consumption for any
non-terminal taskState, which can drop handling when the first state is
"input-required"; update the logic so "input-required" is treated as
non-terminal that must keep the stream open (i.e., do not break when taskState
=== "input-required"), and align this change with the dispatcher by adding
"input-required" to the tracked states in
src/executor/skill-dispatcher-plugin.ts (the arrays/conditions around the
existing "submitted"/"working" checks). Ensure the SSE break condition only
triggers for terminal states ("completed","failed","canceled","rejected") or
when the dispatcher explicitly tracks the non-terminal state.

continue;
}
if (event.kind === "status-update") {
Expand Down
7 changes: 7 additions & 0 deletions src/executor/extensions/blast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ export class BlastRegistry {
this.byKey.clear();
}

clearAgent(agentName: string): void {
const prefix = `${agentName}::`;
for (const key of this.byKey.keys()) {
if (key.startsWith(prefix)) this.byKey.delete(key);
}
}

get size(): number {
return this.byKey.size;
}
Expand Down
33 changes: 32 additions & 1 deletion src/executor/skill-dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,15 @@ export class SkillDispatcherPlugin implements Plugin {
// burn a round-trip on every long-running task against every agent,
// most of which reject. SkillBrokerPlugin refreshes the flag every
// 10 min so capability changes land automatically.
const callbackBaseUrl = process.env.WORKSTACEAN_BASE_URL;
//
// Callback URL routing:
// - Docker-internal agents (hostname like `http://quinn:7870`) use
// WORKSTACEAN_INTERNAL_BASE_URL (default http://workstacean:3000)
// which resolves inside the shared docker network.
// - External agents reached over Tailscale / public networks
// (hostname has a dot, e.g. `http://host.tailnet.ts.net:...`)
// use WORKSTACEAN_BASE_URL — the operator-configured public URL.
const callbackBaseUrl = this._pickCallbackBaseUrl(a2aExecutor.url);
if (callbackBaseUrl && a2aExecutor.pushNotifications) {
const callbackUrl = `${callbackBaseUrl.replace(/\/$/, "")}/api/a2a/callback/${encodeURIComponent(taskId)}`;
void a2aExecutor.registerPushNotification(taskId, callbackUrl, callbackToken, correlationId, parentId)
Expand Down Expand Up @@ -601,6 +609,29 @@ export class SkillDispatcherPlugin implements Plugin {
});
}

/**
* Pick the callback base URL for push notifications based on whether the
* target agent is docker-internal or external. Docker service names are
* short hostnames (no dots); external hosts use FQDNs or IPs.
*
* Internal default: http://workstacean:3000 (resolves on shared docker net).
* External default: process.env.WORKSTACEAN_BASE_URL.
*/
private _pickCallbackBaseUrl(agentUrl: string | undefined): string | undefined {
if (!agentUrl) return process.env.WORKSTACEAN_BASE_URL;
try {
const { hostname } = new URL(agentUrl);
// Docker service names are single-label (no dot, not an IP).
const isDockerInternal = !hostname.includes(".") && !hostname.includes(":");
if (isDockerInternal) {
return process.env.WORKSTACEAN_INTERNAL_BASE_URL ?? "http://workstacean:3000";
}
return process.env.WORKSTACEAN_BASE_URL;
} catch {
return process.env.WORKSTACEAN_BASE_URL;
}
}

private async _fileTriageOnBoard(
github: { title: string; owner?: string; repo?: string; number?: number; url?: string },
triageSummary: string | undefined,
Expand Down
36 changes: 36 additions & 0 deletions src/plugins/skill-broker-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ import {
HITL_MODE_URI,
type HitlMode,
} from "../executor/extensions/hitl-mode.ts";
import {
defaultBlastRegistry,
BLAST_URI,
type BlastRadius,
} from "../executor/extensions/blast.ts";

interface AgentSkill {
name: string;
Expand Down Expand Up @@ -269,6 +274,7 @@ export class SkillBrokerPlugin implements Plugin {
}

this._loadHitlModeDeclarations(agent.name, card);
this._loadBlastDeclarations(agent.name, card);
} catch (err) {
console.debug(`[skill-broker] ${agent.name}: card discovery skipped:`, err instanceof Error ? err.message : err);
}
Expand Down Expand Up @@ -312,6 +318,36 @@ export class SkillBrokerPlugin implements Plugin {
}
}

private _loadBlastDeclarations(agentName: string, card: AgentCard): void {
defaultBlastRegistry.clearAgent(agentName);

const ext = (card.capabilities?.extensions ?? []).find(e => e?.uri === BLAST_URI);
if (!ext) return;

const params = (ext.params ?? {}) as { skills?: Record<string, unknown> };
const entries = params.skills && typeof params.skills === "object" ? params.skills : {};
const validRadii = new Set(["self", "project", "repo", "fleet", "public"]);
let declared = 0;

for (const [skill, rawEntry] of Object.entries(entries)) {
const entry = rawEntry as { radius?: unknown; note?: unknown } | undefined;
if (!entry || typeof entry !== "object") continue;
if (typeof entry.radius !== "string" || !validRadii.has(entry.radius)) continue;

defaultBlastRegistry.declare({
agentName,
skill,
radius: entry.radius as BlastRadius,
...(typeof entry.note === "string" ? { note: entry.note } : {}),
});
declared++;
}

if (declared > 0) {
console.log(`[skill-broker] ${agentName}: loaded ${declared} blast declaration(s)`);
}
}

private async _fetchCard(url: string): Promise<AgentCard | null> {
const baseUrl = url.replace(/\/a2a\/?$/, "");
const factory = new ClientFactory({
Expand Down
Loading