@staix/agent-hub 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +13 -4
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +8 -4
- package/docs/agent-notes/tasks.md +7 -2
- package/docs/agent-notes/tests.md +6 -5
- package/docs/design/adapters.md +76 -0
- package/docs/design/agent-hub-architecture.md +67 -0
- package/docs/design/agent-hub.md +52 -0
- package/docs/design/bus.md +48 -0
- package/docs/design/task-board.md +86 -0
- package/docs/dogfood.md +19 -2
- package/docs/operations.md +337 -15
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +15 -3
- package/docs/sdlc.md +4 -4
- package/docs/security.md +9 -4
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +3 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +12 -1
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -0
- package/docs/verification/2026-11-01-0.27.0.md +49 -0
- package/docs/verification/2026-11-02-0.28.0.md +54 -0
- package/docs/verification/2026-11-02-autonomy-t4.md +39 -0
- package/docs/verification/2026-11-02-codex-0166-reviewer-tool-call.md +53 -0
- package/docs/verification/2026-11-02-control-write-after.json +139 -0
- package/docs/verification/2026-11-02-control-write-baseline.json +127 -0
- package/docs/verification/2026-11-02-control-write-guarded.json +127 -0
- package/docs/verification/2026-11-02-control-write-outcomes.md +65 -0
- package/docs/verification/2026-11-02-orca-1.4.245-cli-contract.md +51 -0
- package/docs/verified.json +13 -13
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +344 -116
- package/src/adapters/claude-channel.ts +12 -2
- package/src/adapters/compat.ts +1 -1
- package/src/adapters/definitions/native-cli-claude-setup.ts +31 -9
- package/src/adapters/definitions/native-cli-entry-jcode.ts +33 -0
- package/src/adapters/definitions/native-cli-entry-qwen.ts +2 -2
- package/src/adapters/jcode.ts +37 -4
- package/src/adapters/qwen-dual.ts +3 -2
- package/src/cli/agents.ts +4 -2
- package/src/cli/bench.ts +4 -2
- package/src/cli/board-read.ts +25 -0
- package/src/cli/claude-homes.ts +2 -2
- package/src/cli/console-state.ts +8 -3
- package/src/cli/help.ts +8 -6
- package/src/cli/identity.ts +14 -6
- package/src/cli/init-prompt.ts +179 -0
- package/src/cli/init-settings.ts +229 -0
- package/src/cli/init.ts +102 -9
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +112 -36
- package/src/cli/output.ts +14 -0
- package/src/cli/setup-homes.ts +18 -3
- package/src/cli/specs.ts +150 -0
- package/src/cli/study-output.ts +56 -0
- package/src/cli/study.ts +26 -3
- package/src/cli/terminal-recovery.ts +35 -11
- package/src/hub/action-policy.ts +5 -3
- package/src/hub/board.ts +56 -3
- package/src/hub/bus.ts +54 -7
- package/src/hub/child-process.ts +6 -4
- package/src/hub/config-apply.ts +4 -1
- package/src/hub/config-edit.ts +1 -1
- package/src/hub/config-tier.ts +1 -0
- package/src/hub/control-client.ts +28 -4
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +324 -64
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/events.ts +2 -1
- package/src/hub/hub-tools.ts +15 -9
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/peers.ts +1 -1
- package/src/hub/project-runtime-state.ts +38 -13
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- package/src/hub/sdlc-release-authorization.ts +11 -5
- package/src/hub/session-identity.ts +1 -1
- package/src/hub/settings.ts +2 -0
- package/src/hub/specs-config.ts +43 -0
- package/src/hub/specs-task.ts +108 -0
- package/src/hub/specs.ts +1008 -0
- package/src/hub/task-list-page.ts +75 -0
- package/src/hub/task-sweep.ts +10 -1
- package/src/hub/tasks.ts +448 -40
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/ui/office-model.ts +17 -1
- package/src/ui/office-space.ts +1 -1
- package/templates/config.json +1 -0
- package/templates/presets/confirmation-matrix.json +5 -5
- package/templates/profiles/dev.json +1 -0
- package/templates/profiles/general.json +1 -0
- package/templates/profiles/research.json +1 -0
- package/templates/profiles/writing.json +1 -0
- package/templates/release-guard.block.md +2 -1
- package/templates/specs.block.md +5 -0
package/src/cli/study.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Argument parsing for `ahub study` (#601). Pure: argv in, one request description or a usage line out, so the I/O in main.ts stays a thin dispatcher and every form is testable without a daemon.
|
|
2
|
-
export const STUDY_USAGE = "usage: ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | approve-protocol <hash> | close <id> <revision> --reason <text> | show [<id> | --after <id>] [--json] [--color=auto|always|never]";
|
|
2
|
+
export const STUDY_USAGE = "usage: ahub study draft <parent> <contract.json> | revise <id> <revision> <contract.json> | approve <id> <revision> | approve-protocol <hash> | close <id> <revision> --reason <text> | show [<id> | --after <id>] | experiments (--campaign <id> [--after <n>] | --attempt <ra-id> [--events-after <n>] | --plan <hash> --baseline <ra-id> --variant <ra-id>) [--json] [--color=auto|always|never]";
|
|
3
3
|
|
|
4
4
|
export type StudyRequest =
|
|
5
5
|
| { op: "draft"; parentTaskId: number; file: string }
|
|
@@ -7,9 +7,10 @@ export type StudyRequest =
|
|
|
7
7
|
| { op: "approve"; id: string; revision: number }
|
|
8
8
|
| { op: "approve_protocol"; hash: string }
|
|
9
9
|
| { op: "close"; id: string; revision: number; reason: string }
|
|
10
|
-
| { op: "show"; id?: string; after?: string }
|
|
10
|
+
| { op: "show"; id?: string; after?: string }
|
|
11
|
+
| { op: "experiments"; campaign?: string; attempt?: string; after?: number; eventsAfter?: number; plan?: string; baseline?: string; variant?: string };
|
|
11
12
|
|
|
12
|
-
const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/;
|
|
13
|
+
const ID = /^rc-[a-f0-9]{12}$/, INT = /^[1-9]\d{0,8}$/, ATTEMPT = /^ra-[a-f0-9]{12}$/, HASH = /^[a-f0-9]{64}$/, CURSOR = /^(0|[1-9]\d{0,8})$/;
|
|
13
14
|
|
|
14
15
|
/** `operands` is argv with the presentation flags (`--json`, `--color=`) already removed by `splitPresentation`. `--reason` takes the rest of the words, as `task cancel --reason` does. */
|
|
15
16
|
export function parseStudyArgs(operands: readonly string[]): StudyRequest | { usage: string } {
|
|
@@ -22,6 +23,28 @@ export function parseStudyArgs(operands: readonly string[]): StudyRequest | { us
|
|
|
22
23
|
const hash = rest[0];
|
|
23
24
|
return hash?.length === 64 && /^[a-f0-9]{64}$/.test(hash) ? { op: "approve_protocol", hash } : usage("a protocol hash is 64 lowercase hexadecimal characters");
|
|
24
25
|
}
|
|
26
|
+
if (sub === "experiments") {
|
|
27
|
+
// Three closed forms, the ones Tasks.experimentShow takes: a campaign page, one attempt, or one comparison. A flag with no value, a repeat, a stray word or a mix is a usage error before anything is sent.
|
|
28
|
+
const names = ["--campaign", "--attempt", "--after", "--events-after", "--plan", "--baseline", "--variant"], seen = new Map<string, string>();
|
|
29
|
+
for (let at = 0; at < rest.length; at += 2) {
|
|
30
|
+
const name = rest[at], value = rest[at + 1];
|
|
31
|
+
if (name === undefined || !names.includes(name) || value === undefined || value.startsWith("--") || seen.has(name)) return usage();
|
|
32
|
+
seen.set(name, value);
|
|
33
|
+
}
|
|
34
|
+
const campaign = seen.get("--campaign"), attempt = seen.get("--attempt"), after = seen.get("--after"), eventsAfter = seen.get("--events-after"), plan = seen.get("--plan"), baseline = seen.get("--baseline"), variant = seen.get("--variant");
|
|
35
|
+
if (campaign !== undefined && !id(campaign)) return usage("a campaign id looks like rc-0123456789ab");
|
|
36
|
+
for (const [name, value] of [["--attempt", attempt], ["--baseline", baseline], ["--variant", variant]] as const) if (value !== undefined && !ATTEMPT.test(value)) return usage(`${name} takes an attempt id like ra-0123456789ab`);
|
|
37
|
+
if (plan !== undefined && !HASH.test(plan)) return usage("a plan hash is 64 lowercase hexadecimal characters");
|
|
38
|
+
if (after !== undefined && !CURSOR.test(after)) return usage("--after takes the cursor a previous page printed");
|
|
39
|
+
if (eventsAfter !== undefined && !CURSOR.test(eventsAfter)) return usage("--events-after takes the event cursor a previous page printed");
|
|
40
|
+
const comparison = plan !== undefined || baseline !== undefined || variant !== undefined;
|
|
41
|
+
if (comparison) return plan !== undefined && baseline !== undefined && variant !== undefined && campaign === undefined && attempt === undefined && after === undefined && eventsAfter === undefined
|
|
42
|
+
? { op: "experiments", plan, baseline, variant } : usage("a comparison needs --plan, --baseline and --variant and nothing else");
|
|
43
|
+
if ((campaign === undefined) === (attempt === undefined)) return usage("give --campaign or --attempt");
|
|
44
|
+
if (attempt !== undefined && after !== undefined) return usage("--after pages a campaign, not one attempt; --events-after pages the events of one attempt");
|
|
45
|
+
if (campaign !== undefined && eventsAfter !== undefined) return usage("--events-after pages the events of one attempt, not a campaign");
|
|
46
|
+
return attempt !== undefined ? { op: "experiments", attempt, ...(eventsAfter === undefined ? {} : { eventsAfter: Number(eventsAfter) }) } : { op: "experiments", campaign: campaign!, ...(after === undefined ? {} : { after: Number(after) }) };
|
|
47
|
+
}
|
|
25
48
|
if (sub === "show") {
|
|
26
49
|
// `--after <id>` continues a list from the cursor a previous page printed; it has no meaning with an id, which shows one campaign.
|
|
27
50
|
if (rest[0] === "--after") {
|
|
@@ -225,7 +225,7 @@ function runnerFailure(argv: readonly string[], result: CommandResult): Recovery
|
|
|
225
225
|
async function run(runner: CommandRunner, argv: readonly string[]): Promise<unknown> {
|
|
226
226
|
const result = await runner(argv);
|
|
227
227
|
if (commandStatus(result) !== 0) {
|
|
228
|
-
// Orca 1.4.223
|
|
228
|
+
// Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a handle whose terminal was removed with `{"ok":false,"error":{"code":"terminal_handle_stale"}}`.
|
|
229
229
|
if (errorCode(result) === "terminal_handle_stale") {
|
|
230
230
|
const handle = argv.includes("--terminal") ? argv[argv.indexOf("--terminal") + 1] : undefined;
|
|
231
231
|
throw new OrcaCommandError({ code: "terminal-gone", message: `the Orca terminal${handle ? ` ${handle}` : ""} no longer exists` });
|
|
@@ -300,17 +300,41 @@ function defaultRunner(executable: string): CommandRunner {
|
|
|
300
300
|
export const orcaExecutable = (): string => resolveOrcaExecutable();
|
|
301
301
|
|
|
302
302
|
/**
|
|
303
|
-
* #269:
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* and compare it if that case shows up.
|
|
303
|
+
* #269: whether Orca is in play for this project: this process runs in an Orca terminal, or an earlier `ahub <peer>`
|
|
304
|
+
* launch of this project recorded a worktree. It is only a hint for a synchronous "could a start open a terminal"
|
|
305
|
+
* answer. It is never the worktree a terminal is opened in: the environment names the worktree of whatever terminal
|
|
306
|
+
* this process was started from, which is another project's when `ahub --project`, the dashboard manager or an upgrade
|
|
307
|
+
* brought this project up (#1089). `resolveStartTerminalWorktree` names the worktree.
|
|
309
308
|
*/
|
|
310
|
-
export function
|
|
311
|
-
if (env.ORCA_WORKTREE_ID) return
|
|
309
|
+
export function startTerminalCandidate(projectRoot: string, stateDir: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
310
|
+
if (env.ORCA_WORKTREE_ID) return true;
|
|
312
311
|
const records = launchRecords(stateDir);
|
|
313
|
-
return records
|
|
312
|
+
return records !== "unreadable" && records.rows.some((row) => row.projectRoot === projectRoot && typeof row.worktreeId === "string");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** #1089: how many worktrees one `orca worktree list` asks for; a longer list is reported as truncated and never read as "none". */
|
|
316
|
+
const WORKTREE_LIST_LIMIT = 500;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* #1089: the Orca worktree whose root is exactly this project's root, asked of Orca and never taken from the caller's
|
|
320
|
+
* environment. The match is exact because `ahub <peer>` compares the terminal's worktree root with the project root by
|
|
321
|
+
* equality and refuses on a difference: a terminal opened in any other worktree would only fail later. The refusal names
|
|
322
|
+
* the cause and the next step and carries no path; Orca's own words go in `detail` for the hub log.
|
|
323
|
+
*/
|
|
324
|
+
export async function resolveStartTerminalWorktree(projectRoot: string, options?: CommandRunner | TerminalRecoveryOptions): Promise<{ ok: true; worktreeId: string } | { ok: false; why: string; detail?: string }> {
|
|
325
|
+
const config = normalizeOptions(options);
|
|
326
|
+
let value: unknown;
|
|
327
|
+
try { value = await run(config.runner, ["worktree", "list", "--limit", String(WORKTREE_LIST_LIMIT), "--json"]); }
|
|
328
|
+
catch (error) {
|
|
329
|
+
return { ok: false, why: "Orca could not list its worktrees, so no terminal was opened. Check that Orca is running (orca worktree list), then start the peer again, or set terminal.open", detail: (error as Error).message };
|
|
330
|
+
}
|
|
331
|
+
const payload = responseResult(value);
|
|
332
|
+
const rows = Array.isArray(payload.worktrees) ? payload.worktrees.map(object) : [];
|
|
333
|
+
const matches = rows.filter((row) => row.isArchived !== true && rootMatches(row.path, projectRoot) && typeof row.id === "string" && row.id.length > 0);
|
|
334
|
+
if (matches.length === 1) return { ok: true, worktreeId: String(matches[0]!.id) };
|
|
335
|
+
if (matches.length > 1) return { ok: false, why: "more than one Orca worktree has this project's root, so no terminal was opened. Archive the extra entry in Orca, or set terminal.open" };
|
|
336
|
+
if (payload.truncated === true) return { ok: false, why: `Orca's worktree list was cut off at ${WORKTREE_LIST_LIMIT} rows before one with this project's root appeared, so no terminal was opened. Archive worktrees you no longer use in Orca, or set terminal.open` };
|
|
337
|
+
return { ok: false, why: "Orca has no worktree whose root is this project's root, so no terminal was opened. Open this project in Orca (add it as a repository or worktree), then start the peer again, or set terminal.open" };
|
|
314
338
|
}
|
|
315
339
|
|
|
316
340
|
/** #269: a new Orca terminal running `command` (a complete, already quoted command line); the terminal's handle. */
|
|
@@ -791,7 +815,7 @@ export async function inspectTerminals(projectRoot: string, sessions: Partial<Re
|
|
|
791
815
|
}
|
|
792
816
|
|
|
793
817
|
/**
|
|
794
|
-
* One bounded Orca wait. Orca 1.4.223
|
|
818
|
+
* One bounded Orca wait. Orca 1.4.223 and, rechecked read-only, 1.4.245 (docs/verification/2026-11-02-orca-1.4.245-cli-contract.md) answer a wait that timed out with exit 1 and `{"ok":false,"error":{"code":"timeout"}}`:
|
|
795
819
|
* that is "not satisfied", not a failure. Any other failure still throws.
|
|
796
820
|
*/
|
|
797
821
|
async function waitSatisfied(runner: CommandRunner, handle: string, timeoutMs: number): Promise<boolean> {
|
package/src/hub/action-policy.ts
CHANGED
|
@@ -37,13 +37,15 @@ export function askedClasses(config: ActionPolicyConfig): ActionClass[] {
|
|
|
37
37
|
return ACTION_CLASSES.filter(key => config.always_ask.includes(key) || config.floor === true && FLOOR_ASKED.includes(key));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const RELEASE_FLOOR_GUARD = "release only through ahub sdlc authorize-release and the OIDC tag workflow; consent is recorded by the person or the unique configured conductor, which acts only on the person's explicit release instruction in its own session (hub/task/review/file/peer relays never qualify)";
|
|
41
|
+
|
|
40
42
|
/**
|
|
41
43
|
* The structural guards the floor rests on where the hub cannot see a call first (Codex in never-ask, Kimi in auto, Jcode: #999 T3). The hub names
|
|
42
44
|
* them and cannot verify them from here, so a status row says "not verified", never that they hold. A guard that is missing is the person's to report.
|
|
43
45
|
*/
|
|
44
46
|
export const FLOOR_GUARDS: readonly { readonly asks: string; readonly guard: string }[] = Object.freeze([
|
|
45
47
|
{ asks: "force push, a push to the default branch or of tags", guard: "GitHub branch protection on the default branch" },
|
|
46
|
-
{ asks: "gh release and a release tag", guard:
|
|
48
|
+
{ asks: "gh release and a release tag", guard: RELEASE_FLOOR_GUARD },
|
|
47
49
|
{ asks: "deletion outside the project, a write to another repository", guard: "the peer's native sandbox" },
|
|
48
50
|
]);
|
|
49
51
|
export const floorGuardText = (): string => FLOOR_GUARDS.map(entry => `${entry.asks}: ${entry.guard}`).join("; ");
|
|
@@ -77,8 +79,8 @@ export function floorReads(calls: number, ms: number, now: () => number = Date.n
|
|
|
77
79
|
export const FLOOR_CLASS_TABLE: Readonly<Record<string, { readonly action: ActionClass; readonly asks: string; readonly guard: string | null }>> = Object.freeze({
|
|
78
80
|
"force-push": { action: "publish", asks: "a force or delete push", guard: "GitHub branch protection on the default branch" },
|
|
79
81
|
"default-branch-push": { action: "publish", asks: "a push to the default branch", guard: "GitHub branch protection on the default branch" },
|
|
80
|
-
"tag-push": { action: "publish", asks: "a push of a tag", guard:
|
|
81
|
-
"release-publish": { action: "publish", asks: "gh release", guard:
|
|
82
|
+
"tag-push": { action: "publish", asks: "a push of a tag", guard: RELEASE_FLOOR_GUARD },
|
|
83
|
+
"release-publish": { action: "publish", asks: "gh release", guard: RELEASE_FLOOR_GUARD },
|
|
82
84
|
"package-publish": { action: "publish", asks: "npm, bun, pnpm, yarn or cargo publish", guard: null },
|
|
83
85
|
"destroy-outside-root": { action: "destroy", asks: "deletion outside the execution root", guard: "the peer's native sandbox" },
|
|
84
86
|
"foreign-repo-write": { action: "external-write", asks: "a write to another repository", guard: "the peer's native sandbox" },
|
package/src/hub/board.ts
CHANGED
|
@@ -18,6 +18,8 @@ import type { SdlcReceipt } from "./sdlc.ts";
|
|
|
18
18
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
19
19
|
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
20
20
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
21
|
+
import type { SpecRef, SpecTaskReport } from "./specs-task.ts";
|
|
22
|
+
export type { SpecAck, SpecRef, SpecTaskReport } from "./specs-task.ts";
|
|
21
23
|
import { campaignHash, parseResearchCampaign, parseResearchHandoff, type ResearchCampaignContract, type ResearchCampaignStatus, type ResearchUnit, type ResearchHandoffV1, type ResearchHandoffRecord } from "./research-campaign.ts";
|
|
22
24
|
|
|
23
25
|
export interface ResearchCampaignRecord {
|
|
@@ -133,7 +135,7 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
|
|
|
133
135
|
export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
|
|
134
136
|
export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
|
|
135
137
|
export interface TaskCancellation { reason: string; supersededBy?: string; flowRevision: number; withheld?: true }
|
|
136
|
-
export const TASK_STALL_CODES = ["tests", "spec", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes", "queue-count", "queue-age", "queue-age-unknown"] as const;
|
|
138
|
+
export const TASK_STALL_CODES = ["tests", "spec", "network", "worktree", "roster", "pull-only", "not-attached", "excluded", "owner-admission", "offline", "paused", "failing", "pii", "policy", "cancelled", "unavailable", "no-writes", "queue-count", "queue-age", "queue-age-unknown"] as const;
|
|
137
139
|
export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
|
|
138
140
|
export interface TaskStall { task: number; signature: string; replanRevision: number; cause: { kind: "reservation" | "no-eligible-peer" | "cancelled-dependency" | "no-writes"; peer?: string; dependency?: number; code: TaskStallCode; turns?: number; acceptance?: number; refusals?: { peer: string; code: TaskStallCode }[] }; at: number }
|
|
139
141
|
|
|
@@ -201,6 +203,10 @@ export interface HistoryEntry {
|
|
|
201
203
|
sweep?: SweepRecord;
|
|
202
204
|
/** The PII screen did not clear this entry's note (#198): only the console reads it. */
|
|
203
205
|
withheld?: true;
|
|
206
|
+
/** Public, head-bound design-spec status; acknowledgement text is screened before this is written. */
|
|
207
|
+
specReport?: SpecTaskReport;
|
|
208
|
+
/** Internal identity for incomplete reports without a full commit SHA; never projected publicly. */
|
|
209
|
+
specRefBinding?: string;
|
|
204
210
|
/** Host evidence at done, never inferred from the owner's summary. */
|
|
205
211
|
noWrites?: true;
|
|
206
212
|
/** Host-recorded expected cost and exact review head, never narrative. */
|
|
@@ -277,6 +283,31 @@ function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
|
277
283
|
if (failure && (!id(failure.peer) || !["start_failed", "quota", "no_verdict"].includes(failure.cause) || failure.head !== undefined && (typeof failure.head !== "string" || !failure.head.trim() || failure.head.length > 300 || Buffer.byteLength(failure.head) > 1200 || /\p{Cc}/u.test(failure.head)))) throw new Error("invalid reviewer failure metadata");
|
|
278
284
|
return { ...(extra.reviewerFamily ? { reviewerFamily: extra.reviewerFamily } : {}), ...(extra.reviewerReason ? { reviewerReason: extra.reviewerReason } : {}), ...(extra.reviewBlocked ? { reviewBlocked: true } : {}), ...(extra.queueReviewHeld ? { queueReviewHeld: true, ...(extra.queueReviewBinding !== undefined ? { queueReviewBinding: extra.queueReviewBinding } : {}) } : {}), ...(failure ? { reviewerFailure: { peer: failure.peer, cause: failure.cause, ...(failure.head ? { head: failure.head } : {}) } } : {}) };
|
|
279
285
|
}
|
|
286
|
+
function specReportHistory(report: unknown, binding: unknown, event: string): Pick<HistoryEntry, "specReport" | "specRefBinding"> {
|
|
287
|
+
if (!report || typeof report !== "object" || Array.isArray(report) || !["done", "done (checking)"].includes(event)
|
|
288
|
+
|| typeof binding !== "string" || !/^[a-f0-9]{64}$/.test(binding)) throw new Error("invalid task spec report metadata");
|
|
289
|
+
const value = report as Record<string, unknown>;
|
|
290
|
+
if (Object.keys(value).some(key => !["head", "complete", "errors", "unchanged", "changed", "truncated", "reason"].includes(key))
|
|
291
|
+
|| typeof value.head !== "string" || value.head !== "" && !/^[a-f0-9]{40}$/.test(value.head)
|
|
292
|
+
|| value.head === "" && value.complete !== false || typeof value.complete !== "boolean"
|
|
293
|
+
|| !Number.isSafeInteger(value.errors) || (value.errors as number) < 0 || (value.errors as number) > 1_000_000
|
|
294
|
+
|| value.truncated !== undefined && value.truncated !== true
|
|
295
|
+
|| value.reason !== undefined && (typeof value.reason !== "string" || value.reason.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.reason))
|
|
296
|
+
|| !Array.isArray(value.unchanged) || value.unchanged.length > 20 || !Array.isArray(value.changed) || value.changed.length > 20) throw new Error("invalid task spec report metadata");
|
|
297
|
+
const ref = (raw: unknown): SpecRef => {
|
|
298
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid task spec report metadata");
|
|
299
|
+
const row = raw as Record<string, unknown>;
|
|
300
|
+
if (Object.keys(row).some(key => !["id", "path", "title", "reason"].includes(key))
|
|
301
|
+
|| typeof row.id !== "string" || !row.id.trim() || row.id.length > 160 || /[\p{Cc}\p{Cf}]/u.test(row.id)
|
|
302
|
+
|| typeof row.path !== "string" || !row.path.trim() || row.path.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.path)
|
|
303
|
+
|| typeof row.title !== "string" || !row.title.trim() || row.title.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.title)
|
|
304
|
+
|| row.reason !== undefined && (typeof row.reason !== "string" || row.reason.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.reason))) throw new Error("invalid task spec report metadata");
|
|
305
|
+
return { id: row.id, path: row.path, title: row.title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
|
|
306
|
+
};
|
|
307
|
+
const specReport: SpecTaskReport = { head: value.head, complete: value.complete, errors: value.errors as number,
|
|
308
|
+
unchanged: value.unchanged.map(ref), changed: value.changed.map(ref), ...(value.truncated === true ? { truncated: true } : {}), ...(typeof value.reason === "string" ? { reason: value.reason } : {}) };
|
|
309
|
+
return { specReport, specRefBinding: binding };
|
|
310
|
+
}
|
|
280
311
|
export interface PlannerBinding { planner: PeerId | null; plannerReason: PlannerReason }
|
|
281
312
|
function plannerValue(value: unknown): PeerId | null {
|
|
282
313
|
if (value == null) return null;
|
|
@@ -1362,6 +1393,26 @@ export class Board {
|
|
|
1362
1393
|
statusTasks(): Pick<Task, "id" | "state" | "owner" | "reviewer">[] {
|
|
1363
1394
|
return this.db.query("SELECT id, state, owner, reviewer FROM tasks WHERE state NOT IN ('approved','cancelled') ORDER BY id").all() as Pick<Task, "id" | "state" | "owner" | "reviewer">[];
|
|
1364
1395
|
}
|
|
1396
|
+
/** Fresh privacy classification inputs only; no task history, title, detail or plan decoding. */
|
|
1397
|
+
peerTaskSignals(peer: string): Pick<Task, "state" | "signals">[] {
|
|
1398
|
+
const rows = this.db.query("SELECT state, signals FROM tasks WHERE owner=? OR reviewer=?").all(peer, peer) as { state: unknown; signals: unknown }[];
|
|
1399
|
+
return rows.map(row => {
|
|
1400
|
+
const signals: unknown = JSON.parse(String(row.signals));
|
|
1401
|
+
if (typeof row.state !== "string" || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
|
|
1402
|
+
return { state: row.state as TaskState, signals: signals as string[] };
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
/** Fresh privacy facts for a bounded explicit reference set; no task history or text decoding. */
|
|
1406
|
+
taskPrivacySignals(ids: readonly number[]): Pick<Task, "id" | "signals">[] {
|
|
1407
|
+
if (!Array.isArray(ids) || ids.length > 64 || ids.some(id => !Number.isSafeInteger(id) || id < 1) || new Set(ids).size !== ids.length) throw new Error("invalid task privacy reference set");
|
|
1408
|
+
if (!ids.length) return [];
|
|
1409
|
+
const rows = this.db.query(`SELECT id, signals FROM tasks WHERE id IN (${ids.map(() => "?").join(",")})`).all(...ids) as { id: unknown; signals: unknown }[];
|
|
1410
|
+
return rows.map(row => {
|
|
1411
|
+
const signals: unknown = JSON.parse(String(row.signals));
|
|
1412
|
+
if (!Number.isSafeInteger(row.id) || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
|
|
1413
|
+
return { id: row.id as number, signals: signals as string[] };
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1365
1416
|
statusReservations(): string[] {
|
|
1366
1417
|
return (this.db.query("SELECT DISTINCT reserved FROM tasks WHERE state NOT IN ('approved', 'cancelled', 'backlog') AND reserved IS NOT NULL ORDER BY reserved").all() as { reserved: string }[]).map(row => row.reserved);
|
|
1367
1418
|
}
|
|
@@ -1454,7 +1505,7 @@ export class Board {
|
|
|
1454
1505
|
|
|
1455
1506
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
1456
1507
|
/** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
|
|
1457
|
-
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1508
|
+
update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "invocation" | "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "conductorManual" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewedHead" | "reviewFindings" | "unmetCriteria" | "deliverables" | "checkKey" | "missingKeys" | "claim" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | "specReport" | "specRefBinding" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
|
|
1458
1509
|
const task = this.get(id);
|
|
1459
1510
|
if (!task) throw new Error(`no task #${id}`);
|
|
1460
1511
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -1480,6 +1531,8 @@ export class Board {
|
|
|
1480
1531
|
const plannerHistory = Object.hasOwn(patch, "planner") || extra.plannerReason !== undefined ? plannerMetadata(planner, extra.plannerReason) : {};
|
|
1481
1532
|
const authorHistory = Object.hasOwn(extra, "planAuthor") ? { planAuthor: plannerValue(extra.planAuthor) } : {};
|
|
1482
1533
|
if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
|
|
1534
|
+
const specMetadata = extra.specReport !== undefined || extra.specRefBinding !== undefined
|
|
1535
|
+
? specReportHistory(extra.specReport, extra.specRefBinding, event) : {};
|
|
1483
1536
|
if (extra.conductorManual !== undefined && (extra.conductorManual !== true || by === "user" || by === "hub" || extra.reason !== "manual"
|
|
1484
1537
|
|| !["assigned", "reassigned", "reserved", "unassigned", "reviewer changed"].includes(event))) throw new Error("invalid conductor manual metadata");
|
|
1485
1538
|
if (extra.invocation && (by !== HUB || event !== "invocation")) throw new Error("invocation audit is host-only");
|
|
@@ -1533,7 +1586,7 @@ export class Board {
|
|
|
1533
1586
|
&& priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
|
|
1534
1587
|
const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
|
|
1535
1588
|
? { ...entry, reviewedHead: entry.reviewFindings?.head ?? priorHead } : entry) : task.history;
|
|
1536
|
-
const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
1589
|
+
const history = [...previousHistory, { at: Date.now(), by, event, ...(extra.invocation ? { invocation: extra.invocation } : {}), ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...specMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.conductorManual === true ? { conductorManual: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(extra.reviewedHead ? { reviewedHead: extra.reviewedHead } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}), ...(["check passed", "check failed"].includes(event) && typeof extra.checkKey === "string" && /^[a-z][a-z0-9_]{0,47}$/.test(extra.checkKey) ? { checkKey: extra.checkKey } : {}), ...(["check passed", "check failed"].includes(event) && Number.isSafeInteger(extra.missingKeys) && extra.missingKeys! >= 0 && extra.missingKeys! <= 100_000 ? { missingKeys: extra.missingKeys } : {}), ...(event === "claim verdict" && extra.claim ? { claim: extra.claim } : {}) }];
|
|
1537
1590
|
this.taskWrite(() => {
|
|
1538
1591
|
const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
|
|
1539
1592
|
.run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
|
package/src/hub/bus.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { remoteEnvelope, appendNote, DIGEST, frame, HUB, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
1
|
+
import { remoteEnvelope, appendNote, DIGEST, frame, HUB, USER, isInvocationKind, keepNotes, MAX_HOP, mergePrefaces, newEnvelope, noteLine, parseMarker, quoteNotes, replyAudience, uniqueMemoryLines, type Envelope, type EnvelopeOpts, type PeerId, type PeerState, type Priority } from "./envelope.ts";
|
|
2
2
|
import type { PeerAdapter, DeliveryReceipt } from "./peers.ts";
|
|
3
3
|
import type { DoingKind } from "./peer-doing.ts";
|
|
4
4
|
import type { QueuePressure } from "./queue-backpressure.ts";
|
|
5
|
-
import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState } from "./delivery-journal.ts";
|
|
5
|
+
import { DeliveryJournal, MAX_ENVELOPE_BODY, type JournalDelivery, type JournalDeliveryState, type JournalResolution } from "./delivery-journal.ts";
|
|
6
6
|
import { DELIVERY_RETRY_WARNING } from "./delivery-hints.ts";
|
|
7
7
|
import { MAX_DEFERRED_HUB, RecoveryDeferredHubError, validateDeferredHub } from "./deferred-hub.ts";
|
|
8
8
|
import { parseRemotePeerId } from "../rooms/application.ts";
|
|
@@ -617,6 +617,31 @@ export class Bus {
|
|
|
617
617
|
this.resolveDelivery(id, row.revision, "retry", "recipient requested one uncertain redelivery", `peer:${peer}`);
|
|
618
618
|
}
|
|
619
619
|
|
|
620
|
+
/** Settle only this peer's own uncertain receipt; this never replays its originals or changes task state. */
|
|
621
|
+
resolveOwnDelivery(peer: string, id: string, revision: number, action: "completed" | "discard", reason: string): void {
|
|
622
|
+
const refuse = (message: string): never => { throw new PreEffectToolRefusal(message); };
|
|
623
|
+
// One message per cause class, each naming its next step. An unknown id, another peer's row and a row that is not needs_review share one message so a refusal never confirms another peer's row.
|
|
624
|
+
if (peer === USER || peer === HUB || remoteId(peer)) return refuse("hub_delivery_resolve settles a local agent's own held delivery; the person settles any other row with ahub queue resolve <delivery-id>");
|
|
625
|
+
if (this.closed || this.recoveryHeld || this.storageError || !this.journal) return refuse("the hub is not accepting delivery changes now (recovery, shutdown or delivery storage); run ahub status, then call hub_delivery_resolve again after it recovers");
|
|
626
|
+
if (this.paused.has(peer) || this.opts.deliveryHeld?.(peer) === true) return refuse("your deliveries are paused or held, so this row cannot be settled now; run ahub status to see which, and call hub_delivery_resolve again once it is released");
|
|
627
|
+
if (!Number.isSafeInteger(revision) || revision < 0 || (action !== "completed" && action !== "discard") || typeof reason !== "string" || !reason.trim() || reason.trim().length > 1000) return refuse("hub_delivery_resolve needs the revision hub_inbox showed (a whole number), action completed or discard, and a reason of 1 to 1000 characters");
|
|
628
|
+
const row = this.queueShow(id), actor = `peer:${peer}`, cleanReason = reason.trim();
|
|
629
|
+
const notYours = "no needs_review delivery with this id is held for you; call hub_inbox to list your held deliveries and their revisions";
|
|
630
|
+
if (!row || row.peer !== peer) return refuse(notYours);
|
|
631
|
+
// An uncertain invocation may still be running: a peer claiming it completed would make the caller wait for a result that may never come. Only the person settles it.
|
|
632
|
+
if (row.originals.some(env => isInvocationKind(env))) return refuse(`delivery ${id} carries an invocation whose outcome is uncertain; only the person settles it (ahub queue show ${id}, then ahub queue resolve ${id})`);
|
|
633
|
+
const previous = this.journal.resolution(id);
|
|
634
|
+
if (previous) {
|
|
635
|
+
if (previous.actor !== actor || previous.action !== action || previous.reason !== cleanReason) return refuse(`delivery ${id} was already settled with a different action or reason; the person can inspect it with ahub queue show ${id}`);
|
|
636
|
+
if (revision !== previous.observedRevision && revision !== row.revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
|
|
637
|
+
this.resolveDelivery(id, revision, action, cleanReason, actor);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
if (row.state !== "needs_review") return refuse(notYours);
|
|
641
|
+
if (row.revision !== revision) return refuse("held delivery revision changed; read hub_inbox again for the current revision");
|
|
642
|
+
this.resolveDelivery(id, revision, action, cleanReason, actor);
|
|
643
|
+
}
|
|
644
|
+
|
|
620
645
|
private retryOut(peer: string, originals: Envelope[], out: Envelope[]): Envelope[] {
|
|
621
646
|
if (remoteId(peer)) return out;
|
|
622
647
|
if (!out.length || !this.journal || !this.journal.retryWarning(peer, originals)) return out;
|
|
@@ -631,6 +656,11 @@ export class Bus {
|
|
|
631
656
|
});
|
|
632
657
|
}
|
|
633
658
|
|
|
659
|
+
/** #1096: the operator action that settled this delivery, when one did (`queue show` reads it to say what is left to do). */
|
|
660
|
+
deliveryResolution(id: string): JournalResolution | undefined { return this.journal && !this.storageError ? this.journal.resolution(id)?.action : undefined; }
|
|
661
|
+
/** #1096: ids of the deliveries an operator action already settled, in one read; empty without a journal. `queue list` hides them unless asked for all. */
|
|
662
|
+
settledIds(): Set<string> { return this.journal && !this.storageError ? this.journal.resolvedIds() : new Set(); }
|
|
663
|
+
|
|
634
664
|
resolveDelivery(id: string, revision: number, action: "completed" | "retry" | "discard", reason: string, actor: string = "user"): void {
|
|
635
665
|
if (!this.journal || this.storageError) throw new Error("delivery journal unavailable");
|
|
636
666
|
this.trackingQueuePressure();
|
|
@@ -638,7 +668,13 @@ export class Bus {
|
|
|
638
668
|
if (!record) throw new Error("unknown delivery");
|
|
639
669
|
const previous = this.journal.resolution(id);
|
|
640
670
|
if (previous) {
|
|
641
|
-
|
|
671
|
+
// #1096: a row that already carries a resolution is settled. The same request again is idempotent (the journal verifies it); any other request is not a
|
|
672
|
+
// stale read, and `retry` in particular leaves its parent in `failed` for good, because the redelivery is a new row. Say so instead of "stale revision".
|
|
673
|
+
try { this.journal.resolve(id, revision, action, reason, revision, actor); }
|
|
674
|
+
catch (error) {
|
|
675
|
+
if (/stale delivery revision or conflicting operator resolution/.test(error instanceof Error ? error.message : "")) throw new Error(`delivery already resolved by ${previous.action}`);
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
642
678
|
return;
|
|
643
679
|
}
|
|
644
680
|
if (action === "retry" && remoteId(record.peer)) {
|
|
@@ -648,12 +684,14 @@ export class Bus {
|
|
|
648
684
|
if (!Number.isSafeInteger(deadline) || deadline! <= Date.now()) throw new PreEffectToolRefusal("remote original deadline unavailable or expired; discard it or send a new message explicitly");
|
|
649
685
|
this.checkRemotePublication(record.originals[0]!);
|
|
650
686
|
}
|
|
651
|
-
const recipientConsent = actor === `peer:${record.peer}` && action === "retry"
|
|
687
|
+
const recipientConsent = actor === `peer:${record.peer}` && record.state === "needs_review" && (action === "retry" || action === "completed" || action === "discard");
|
|
652
688
|
// #713: a `queued` delivery was never handed to the peer, so nothing is in flight and a busy peer (a pull-only TUI is busy most of the time) does not block
|
|
653
689
|
// discarding it. `dispatching` and `accepted` stay refused, and every other resolution keeps the busy fence.
|
|
654
690
|
if (["dispatching", "accepted"].includes(record.state)) throw new Error("active delivery cannot be resolved");
|
|
655
691
|
const queuedDiscard = record.state === "queued" && action === "discard";
|
|
656
|
-
|
|
692
|
+
// #1096: a `failed` row was refused before it ran and discarding it touches no queue, so a busy peer has nothing in flight to protect either.
|
|
693
|
+
const failedDiscard = record.state === "failed" && action === "discard";
|
|
694
|
+
if (this.peers.get(record.peer)?.state === "busy" && !recipientConsent && !queuedDiscard && !failedDiscard) throw new Error("peer busy: its delivery cannot be resolved while it works");
|
|
657
695
|
if (record.revision !== revision) throw new Error("stale delivery revision");
|
|
658
696
|
if (record.state === "queued" && action === "retry") throw new Error("queued delivery is already scheduled");
|
|
659
697
|
if (action === "retry" && !remoteId(record.peer) && (this.queues.get(record.peer)?.length ?? 0) + record.originals.length > this.opts.queueCap) throw new Error("recipient queue is full");
|
|
@@ -1435,10 +1473,10 @@ export class Bus {
|
|
|
1435
1473
|
* terminal row. The completed row keeps the envelopes (`ahub queue show <id>`) to resend by hand; take/confirm in two
|
|
1436
1474
|
* steps if loss is ever observed.
|
|
1437
1475
|
*/
|
|
1438
|
-
pull(id: PeerId): Envelope[] | undefined {
|
|
1476
|
+
pull(id: PeerId, options: { allowNeedsReviewHold?: boolean } = {}): Envelope[] | undefined {
|
|
1439
1477
|
if (remoteId(id)) return undefined; // A local inbox read is not signed remote queue admission.
|
|
1440
1478
|
if (this.storageError) throw new Error("delivery journal unavailable");
|
|
1441
|
-
if (this.held(id)) return undefined;
|
|
1479
|
+
if (this.held(id) && !(options.allowNeedsReviewHold === true && this.pullMayReadBehindNeedsReview(id))) return undefined;
|
|
1442
1480
|
const queue = this.queues.get(id) ?? [];
|
|
1443
1481
|
this.dropIrrelevant(id, queue);
|
|
1444
1482
|
const preface = this.prefaces.get(id);
|
|
@@ -1472,6 +1510,15 @@ export class Bus {
|
|
|
1472
1510
|
return out;
|
|
1473
1511
|
}
|
|
1474
1512
|
|
|
1513
|
+
/** Explicit inbox reads may pass only a needs_review hold; every other recovery fence still blocks. */
|
|
1514
|
+
private pullMayReadBehindNeedsReview(id: PeerId): boolean {
|
|
1515
|
+
if (!this.journal || this.closed || this.storageError || !this.isRostered(id) || this.recoveryHeld || this.paused.has(id) || this.opts.deliveryHeld?.(id) === true) return false;
|
|
1516
|
+
if (this.pendingOutcomes.some(row => row.peer === id)) return false;
|
|
1517
|
+
const unresolved = this.journal.unresolved(id);
|
|
1518
|
+
return unresolved.some(row => row.state === "needs_review") &&
|
|
1519
|
+
unresolved.every(row => row.state === "needs_review" || row.state === "queued");
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1475
1522
|
private async drain(id: PeerId): Promise<void> {
|
|
1476
1523
|
if (this.storageError || this.draining.has(id) || !this.peers.has(id)) return;
|
|
1477
1524
|
if (remoteId(id)) this.expireRemoteQueues();
|
package/src/hub/child-process.ts
CHANGED
|
@@ -7,6 +7,8 @@ import { withoutOrcaEnv } from "./orca-env.ts";
|
|
|
7
7
|
* lifecycle calls could bypass the machine recovery lock.
|
|
8
8
|
*/
|
|
9
9
|
export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
10
|
+
// Keep the caller's environment for person-owned native launch paths; peer and helper
|
|
11
|
+
// children that must not inherit Orca use peerChildEnv or withoutOrcaEnv explicitly.
|
|
10
12
|
const env = { ...source };
|
|
11
13
|
delete env.AGENTHUB_RECOVERY_OPERATION;
|
|
12
14
|
delete env.AGENTHUB_RECOVERY_CONDUCTOR;
|
|
@@ -20,15 +22,15 @@ export function childEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.Proces
|
|
|
20
22
|
* typed or started there has the console's identity, whatever started the hub.
|
|
21
23
|
*/
|
|
22
24
|
export function personEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
23
|
-
// The Orca terminal the hub itself was started in is not this new terminal: a launch there must not record it as its own, so every ORCA_ variable goes (#613).
|
|
24
25
|
const env = withoutOrcaEnv(childEnv(source));
|
|
25
26
|
for (const name of ["CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "AGENTHUB_CHANNEL", "AGENTHUB_PEER_ID", "AGENTHUB_UNATTENDED"]) delete env[name];
|
|
26
27
|
return env;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
/** Every native child owns its marker; caller vendor markers must not leak across peers (#193). */
|
|
30
|
-
export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
31
|
-
const
|
|
31
|
+
export function peerChildEnv(peer: string, source: NodeJS.ProcessEnv = process.env, options: { preserveOrca?: true } = {}): NodeJS.ProcessEnv {
|
|
32
|
+
const inherited = childEnv(source);
|
|
33
|
+
const env = options.preserveOrca ? inherited : withoutOrcaEnv(inherited);
|
|
32
34
|
delete env.CLAUDECODE;
|
|
33
35
|
delete env.CLAUDE_CODE_SESSION_ID;
|
|
34
36
|
delete env.CODEX_THREAD_ID;
|
|
@@ -60,7 +62,7 @@ export function parseProcessTable(text: string): ProcRow[] {
|
|
|
60
62
|
|
|
61
63
|
const PS = ["ps", "-axo", "pid=,ppid=,pgid=,stat=,lstart=,command="];
|
|
62
64
|
// `lstart` is local time: one zone for every reader, or a reader in another zone sees every identity as changed.
|
|
63
|
-
const PS_OPTIONS = { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, detached: true } as const;
|
|
65
|
+
const PS_OPTIONS = { env: { ...withoutOrcaEnv(childEnv(process.env)), LC_ALL: "C", TZ: "UTC" }, detached: true } as const;
|
|
64
66
|
/** The rows of one read, or undefined when they do not show the reader: a parse that found nothing is not a table. */
|
|
65
67
|
const own = (text: string) => { const rows = parseProcessTable(text); return rows.some((row) => row.pid === process.pid) ? rows : undefined; };
|
|
66
68
|
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { peerRosterConfig, staleInstanceKey } from "./roster.ts";
|
|
|
27
27
|
import { CHARACTER_IDS } from "../pet/characters.ts";
|
|
28
28
|
import { autonomyOwnership, checkSettingValue, settingDef } from "./settings.ts";
|
|
29
29
|
import { petPalette } from "../pet/config.ts";
|
|
30
|
+
import { parseSpecsConfig } from "./specs-config.ts";
|
|
30
31
|
|
|
31
32
|
export type ApplyClass = "live" | "next use" | "restart";
|
|
32
33
|
export interface ApplyRule { applies: ApplyClass; reason?: string }
|
|
@@ -202,6 +203,7 @@ const schema: Record<string, Check> = {
|
|
|
202
203
|
pii: block({ paths: strings }), data: block({ readonly: strings }),
|
|
203
204
|
text_projection: v => { projectionConfig(v); },
|
|
204
205
|
corpus: v => { corpusConfig(v); },
|
|
206
|
+
specs: (v, k) => { block({ enabled: bool, review: words(["off", "report", "require"]), roots: strings, stamps_file: text })(v, k); parseSpecsConfig(v); },
|
|
205
207
|
conductor: block({ feed: words(["own", "all", "off"]), approval_wait_s: number(0, 2_147_483_647 / 1000) }),
|
|
206
208
|
budget: block({ gate: number(0, 1), stale_min: timerMinutes, poll_min: timerMinutes, checkpoint_timeout_s: timerSeconds, kimi_tokens_5h: number(0), wait_max_min: number(0) }),
|
|
207
209
|
context: block({ gate: number(0, 1), stale_min: timerMinutes, max_assignment_used: number(0, 1) }),
|
|
@@ -271,7 +273,7 @@ const restart: Record<string, string> = {
|
|
|
271
273
|
profile: "Session tool schemas and profile contracts are bound at startup", profile_overrides: "Session tool schemas and profile contracts are bound at startup",
|
|
272
274
|
text_projection: "Converter and cache ownership are bound at startup", corpus: "The corpus index is bound to its roots at startup", terminal: "Terminal provider is supplied by the daemon lifecycle", bench: "Benchmark execution environment is selected before work starts",
|
|
273
275
|
};
|
|
274
|
-
const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet"]);
|
|
276
|
+
const live = new Set(["action_policy", "permission", "roster", "roles", "planner", "conductor", "protect_instruction_files", "task_sweep", "capabilities", "invocation", "research", "snapshots", "tasks.wip_limits", "tasks.escalate_after", "tasks.auto_pull", "tasks.queue_backpressure", "schedules", "approvals.notify", "pet", "specs"]);
|
|
275
277
|
const nextMemory = new Set(["memory.inject_tokens", "memory.brief_items", "memory.suggest_edges"]);
|
|
276
278
|
/** Closed top-level schema; leaf overrides retain the existing session/runtime boundaries. */
|
|
277
279
|
export function configApply(key: string, scopes: readonly ConfigIdentityScope[] = []): ApplyRule {
|
|
@@ -279,6 +281,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
|
|
|
279
281
|
if (!(Object.hasOwn(schema, top) || top === "permission" || top === "routing")) return fail(key, "unknown configuration key");
|
|
280
282
|
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
281
283
|
const special: Record<string, readonly string[]> = {
|
|
284
|
+
specs: ["enabled", "review", "roots", "stamps_file"],
|
|
282
285
|
task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
283
286
|
checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"], start: ["agents"],
|
|
284
287
|
};
|
package/src/hub/config-edit.ts
CHANGED
|
@@ -132,7 +132,7 @@ function topLevel(lines: readonly string[]): { end: number; spans: Map<string, [
|
|
|
132
132
|
* Edits `mode` and `required_checks` line by line. Every line the edit does not touch keeps its own ending, so a one-key change never
|
|
133
133
|
* rewrites a CRLF checkout; a line the edit writes or adds takes the ending of the first line break the file has (LF for a new file).
|
|
134
134
|
*/
|
|
135
|
-
function editSdlcText(text: string | null, set: ConfigEditSet[]): string | null {
|
|
135
|
+
export function editSdlcText(text: string | null, set: ConfigEditSet[]): string | null {
|
|
136
136
|
const raw = text === null ? [] : text.split("\n");
|
|
137
137
|
const trailing = raw.length > 0 && raw.at(-1) === "";
|
|
138
138
|
if (trailing) raw.pop();
|
package/src/hub/config-tier.ts
CHANGED
|
@@ -39,6 +39,7 @@ const isObject = (value: unknown): value is Record<string, unknown> => !!value &
|
|
|
39
39
|
export function configTier(file: ConfigEditFile, path: readonly string[], value?: unknown, held?: unknown): ConfigTier {
|
|
40
40
|
if (!path.length || path.some(part => typeof part !== "string" || !part || part.length > 64 || UNSAFE.has(part) || /[\s\x00-\x1f]/.test(part))) return refuse("invalid configuration path");
|
|
41
41
|
const key = path.join(".");
|
|
42
|
+
if (file === "config.local.json" && path[0] === "specs" && path.length === 2 && ["enabled", "review", "roots", "stamps_file"].includes(path[1]!)) return { tier: 2 };
|
|
42
43
|
if (file === "sdlc.toml") return path.length === 1 && (SDLC_KEYS as readonly string[]).includes(path[0]!) ? { tier: 2 } : refuse(`sdlc.${key} is not editable here: only mode and required_checks are; edit .agenthub/sdlc.toml in your editor`);
|
|
43
44
|
if (registryKey(key)) return { refused: `${key} is already editable with ahub settings set ${key}; nothing was written` };
|
|
44
45
|
if (path[0] === "research" && path[1] === "runners") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { projectContext } from "./project.ts";
|
|
4
|
+
import { newToolRequestId, TOOL_WRITE_FEATURE, toolWriteOperation, toolWriteUnknownReply, toolWriteRefusedReply } from "./tool-request.ts";
|
|
4
5
|
import { externalAdapterMetadata, externalInstanceCatalog, type ExternalInstanceCatalog, type ExternalAdapterMetadata } from "../adapters/external-metadata.ts";
|
|
5
6
|
import { negotiateProtocol, protocolWindow, type ProtocolWindow } from "./protocol-window.ts";
|
|
6
7
|
|
|
@@ -12,7 +13,8 @@ export function stateDirFor(cwd: string): string {
|
|
|
12
13
|
export const PROTOCOL = 17;
|
|
13
14
|
/** The descriptor's legacy protocol field remains this floor for previous published clients. */
|
|
14
15
|
export const PROTOCOL_MIN = 16;
|
|
15
|
-
|
|
16
|
+
// Literal metadata is read without executing a recovery target; consistency is pinned in tests.
|
|
17
|
+
export const DAEMON_FEATURES = ["project_autonomy", "conductor_recovery", "encrypted_rooms", "external_adapter_instances", "external_adapter_modules", "configured_acp", "native_hook_launch", "task_cancel", "invocation", "recovery_deferred_hub", "peer_instances", "role_stages", "task_planners", "release_trains", "reviewer_pool", "native_tui_launch", "terminal_owner", "cli_audit_delegated", "action_policy_floor", "calendar_schedules", "research_records", "research_dispatch", "research_dropped_offers", "research_experiments", "research_comparisons", "research_runners", "task_handoff", "session_identity", "schema_guard", "design_specs", "queue_discard_states", "tool_write_idempotency"] as const;
|
|
16
18
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
17
19
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
|
|
18
20
|
|
|
@@ -221,20 +223,42 @@ export class ControlClient {
|
|
|
221
223
|
request(msg: Record<string, unknown>, timeoutMs = 30_000, signal?: AbortSignal): Promise<any> {
|
|
222
224
|
if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
223
225
|
const invocation = msg.t === "task" && msg.op === "hub_invoke";
|
|
226
|
+
const operation = toolWriteOperation(msg), write = operation !== undefined;
|
|
227
|
+
const keyedWrite = write && operation !== "hub_delivery_done";
|
|
228
|
+
if (keyedWrite && msg.args && typeof msg.args === "object" && !Array.isArray(msg.args) && Object.hasOwn(msg.args, "request_id")) {
|
|
229
|
+
const { request_id: key, ...args } = msg.args as Record<string, unknown>;
|
|
230
|
+
if (typeof key !== "string" || !key || key.length > 256 || /[\p{Cc}\p{Cf}]/u.test(key)
|
|
231
|
+
|| msg.requestId !== undefined && msg.requestId !== key) {
|
|
232
|
+
return Promise.resolve(toolWriteRefusedReply(msg, undefined, "request_id must be a non-empty echoed request key and agree with requestId"));
|
|
233
|
+
}
|
|
234
|
+
msg = { ...msg, args, requestId: key };
|
|
235
|
+
}
|
|
236
|
+
if (keyedWrite && msg.requestId !== undefined && !this.features.includes(TOOL_WRITE_FEATURE)) {
|
|
237
|
+
return Promise.resolve(toolWriteRefusedReply(msg, typeof msg.requestId === "string" ? msg.requestId : undefined,
|
|
238
|
+
"this daemon does not advertise replay protection; nothing was resubmitted"));
|
|
239
|
+
}
|
|
240
|
+
let wire = msg;
|
|
241
|
+
if (keyedWrite && this.features.includes(TOOL_WRITE_FEATURE) && msg.requestId === undefined) {
|
|
242
|
+
try { wire = { ...msg, requestId: newToolRequestId(this.instanceId ?? "") }; }
|
|
243
|
+
catch { return Promise.resolve({ ok: false, error: "write request identity is unavailable; inspect ahub status before retrying" }); }
|
|
244
|
+
}
|
|
245
|
+
const requestId = typeof wire.requestId === "string" ? wire.requestId : undefined;
|
|
246
|
+
const uncertain = (cause: string) => toolWriteUnknownReply(wire, requestId,
|
|
247
|
+
this.features.includes(TOOL_WRITE_FEATURE) ? cause : `${cause}; this daemon does not advertise replay protection`);
|
|
224
248
|
timeoutMs = controlRequestTimeout(msg, timeoutMs);
|
|
225
249
|
if (signal?.aborted) return Promise.resolve({ ok: false, error: "hub request cancelled before sending" });
|
|
226
250
|
const rid = this.nextRid++;
|
|
227
251
|
return new Promise((resolve) => {
|
|
228
252
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
229
253
|
const done = (reply: any) => {
|
|
230
|
-
this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(reply);
|
|
254
|
+
this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(write && reply?.error === "hub connection closed" ? uncertain(reply.error) : reply);
|
|
231
255
|
};
|
|
232
256
|
const abandon = (error: string) => {
|
|
233
257
|
if (!this.pending.has(rid)) return;
|
|
234
258
|
if (invocation && this.ws.readyState === WebSocket.OPEN) {
|
|
235
259
|
try { this.ws.send(JSON.stringify({ t: "request_cancel", request: rid })); } catch { /* Socket close also withdraws admission. */ }
|
|
236
260
|
}
|
|
237
|
-
done({ ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
|
|
261
|
+
done(write ? uncertain(error) : { ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
|
|
238
262
|
};
|
|
239
263
|
const aborted = () => abandon("hub request cancelled");
|
|
240
264
|
this.pending.set(rid, done);
|
|
@@ -242,7 +266,7 @@ export class ControlClient {
|
|
|
242
266
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
243
267
|
if (signal?.aborted) { aborted(); return; }
|
|
244
268
|
timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
|
|
245
|
-
try { this.ws.send(JSON.stringify({ ...
|
|
269
|
+
try { this.ws.send(JSON.stringify({ ...wire, rid })); }
|
|
246
270
|
catch { abandon("hub request could not be sent"); }
|
|
247
271
|
});
|
|
248
272
|
}
|