@staix/agent-hub 0.27.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 +23 -0
- package/README.md +11 -3
- package/docs/agent-notes/adapters.md +4 -2
- package/docs/agent-notes/bus.md +4 -4
- package/docs/agent-notes/daemon.md +7 -3
- package/docs/agent-notes/tasks.md +5 -0
- 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 +16 -1
- package/docs/operations.md +308 -12
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +11 -3
- package/docs/sdlc.md +1 -1
- package/docs/security.md +8 -3
- package/docs/smoke.md +2 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +2 -0
- package/docs/verification/2026-10-31-codex-0166-reviewer-isolation.md +2 -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 +338 -112
- 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 +6 -5
- package/src/cli/identity.ts +9 -1
- 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 +106 -35
- 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/terminal-recovery.ts +35 -11
- 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 +304 -56
- package/src/hub/delivery-hints.ts +6 -3
- package/src/hub/delivery-journal.ts +14 -3
- package/src/hub/hub-tools.ts +10 -6
- package/src/hub/peers.ts +1 -1
- package/src/hub/review-brief.ts +18 -0
- package/src/hub/routing.ts +7 -3
- 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/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/specs.block.md +5 -0
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
|
}
|
|
@@ -6,19 +6,35 @@ export interface CostTask { repo: string; pr: number; private?: boolean; history
|
|
|
6
6
|
export interface CostPr { repo: string; pr: number; state: "OPEN" | "CLOSED" | "MERGED"; mergedAt?: number; complete: boolean; ciRuns?: { id: number; attempt: number }[]; reviews?: number }
|
|
7
7
|
export interface CostRow { repo: string; pr: number; reviewRounds: number; ciRuns: number | null; ciReruns: number | null; fullReReviews: number; deltaReviews: number; reviewMs: number | null; unknownReviewScopes: number; githubReviews: number | null; complete: boolean }
|
|
8
8
|
export interface CostReport { rows: CostRow[]; truncated: boolean }
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* #1087: why a cost scope was refused. The message is fixed text that names the field and its allowed values and never the value given, so a call site may show it
|
|
11
|
+
* as it is. Only this class is passed through; any other error keeps the generic refusal.
|
|
12
|
+
*/
|
|
13
|
+
export class CostScopeError extends Error {
|
|
14
|
+
constructor(message: string) { super(message); this.name = "CostScopeError"; }
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `field` says how the caller's own argument is named, so the refusal points at what the caller actually wrote: `plan` for a task plan (`plan.review.scope`), empty for the
|
|
18
|
+
* plan-review tool's own `review` and `ci` arguments (`review.scope`). One string covers both keys; an object names each key's source separately, for a call that mixes them.
|
|
19
|
+
*/
|
|
20
|
+
export function cleanCostPlan(input: unknown, field: string | { review: string; ci: string } = "plan"): CostPlan {
|
|
21
|
+
const at = (name: string) => { const prefix = typeof field === "string" ? field : field[name.split(".")[0] as "review" | "ci"]; return prefix ? `${prefix}.${name}` : name; };
|
|
22
|
+
const refuse = (message: string): never => { throw new CostScopeError(message); };
|
|
23
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) refuse(`${typeof field === "string" && field ? field : "the request"} must be an object; review and ci are its cost scope fields`);
|
|
11
24
|
const value = input as Record<string, unknown>;
|
|
12
25
|
const result: CostPlan = {};
|
|
13
26
|
if (value.review !== undefined) {
|
|
14
27
|
const review = value.review as Record<string, unknown>;
|
|
15
|
-
if (!review || typeof review !== "object" || Array.isArray(review)
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
if (!review || typeof review !== "object" || Array.isArray(review)) refuse(`${at("review")} must be an object with scope (none, delta or full) and reviewers (a whole number)`);
|
|
29
|
+
if (Object.keys(review).some(key => !["scope", "reviewers"].includes(key))) refuse(`${at("review")} takes only scope and reviewers`);
|
|
30
|
+
if (!["none", "delta", "full"].includes(review.scope as string)) refuse(`${at("review.scope")} must be none, delta or full`);
|
|
31
|
+
if (!Number.isSafeInteger(review.reviewers)) refuse(`${at("review.reviewers")} must be a whole number: 0 when scope is none, 1 to 8 when scope is delta or full`);
|
|
32
|
+
if (review.scope === "none" && review.reviewers !== 0) refuse(`${at("review.reviewers")} must be 0 when scope is none`);
|
|
33
|
+
if (review.scope !== "none" && ((review.reviewers as number) < 1 || (review.reviewers as number) > 8)) refuse(`${at("review.reviewers")} must be 1 to 8 when scope is delta or full`);
|
|
18
34
|
result.review = { scope: review.scope as ReviewScope["scope"], reviewers: review.reviewers as number };
|
|
19
35
|
}
|
|
20
36
|
if (value.ci !== undefined) {
|
|
21
|
-
if (!["focused", "full", "reuse"].includes(value.ci as string))
|
|
37
|
+
if (!["focused", "full", "reuse"].includes(value.ci as string)) refuse(`${at("ci")} must be focused, full or reuse`);
|
|
22
38
|
result.ci = value.ci as CostPlan["ci"];
|
|
23
39
|
}
|
|
24
40
|
return result;
|
package/src/hub/crash.ts
CHANGED
|
@@ -97,3 +97,23 @@ export function lossNotice(lost: JournalDelivery[], taskTitle: (id: number) => s
|
|
|
97
97
|
"Check your work against them before you go on.",
|
|
98
98
|
].join("\n");
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One line of the crash-recovery report in `ahub status` (#1082). The report is a list of these, kept for the whole run, and what `ahub status` shows is `openCrashNotices` of it, computed when
|
|
103
|
+
* status is asked, so a line whose condition is gone is gone.
|
|
104
|
+
* - `peer` set: the line asks something of that peer's owner ("run ahub codex again", "reconnects by itself if its client is still running"). It stands while that peer is not attached, and clears
|
|
105
|
+
* when it is, whoever started it.
|
|
106
|
+
* - `inFlight` set: the headline. Its count of deliveries the crash left in needs_review is read live, and the clause disappears at zero.
|
|
107
|
+
* - neither: what recovery found or did ("kimi resumed", "pi not resumed (...)"). It is history, not an open action, and stays.
|
|
108
|
+
*/
|
|
109
|
+
export interface CrashNotice { text: string; peer?: string; inFlight?: true }
|
|
110
|
+
|
|
111
|
+
/** The lines `ahub status` shows now: `attached` are the peers that are not offline, `inFlight` is how many of the crash's own deliveries are still in needs_review. */
|
|
112
|
+
export function openCrashNotices(notices: readonly CrashNotice[], live: { attached: ReadonlySet<string>; inFlight: number }): string[] {
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
for (const notice of notices) {
|
|
115
|
+
if (notice.peer !== undefined && live.attached.has(notice.peer)) continue;
|
|
116
|
+
lines.push(notice.inFlight ? `${notice.text}${live.inFlight ? `; ${live.inFlight} deliveries it had in flight are in needs_review (ahub queue list)` : ""}` : notice.text);
|
|
117
|
+
}
|
|
118
|
+
return lines;
|
|
119
|
+
}
|