@staix/agent-hub 0.27.0 → 0.29.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 +33 -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 +9 -3
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +7 -0
- package/docs/agent-notes/tests.md +7 -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 +21 -3
- package/docs/operations.md +370 -12
- package/docs/profiles.md +1 -0
- package/docs/quickstart.md +19 -3
- package/docs/sdlc.md +2 -2
- package/docs/security.md +24 -4
- 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/verification/2026-11-03-0.29.0.md +59 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.json +241 -0
- package/docs/verification/2026-11-03-control-write-stall-windows.md +20 -0
- package/docs/verification/2026-11-03-live-board-latency.json +556 -0
- package/docs/verification/2026-11-03-live-board-latency.md +64 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +857 -125
- 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 +19 -0
- package/src/cli/recovery-package.ts +9 -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 +59 -5
- 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 +43 -4
- package/src/hub/control-write-trace.ts +409 -0
- package/src/hub/cost-governance.ts +22 -6
- package/src/hub/crash.ts +20 -0
- package/src/hub/daemon.ts +470 -98
- 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 +14 -3
- package/src/hub/runtime-capture-prune.ts +4 -1
- package/src/hub/sdlc-release-authorization.ts +13 -4
- package/src/hub/sdlc-release.ts +23 -10
- package/src/hub/sdlc.ts +15 -2
- 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/task-worktree.ts +331 -0
- package/src/hub/tasks.ts +606 -53
- package/src/hub/terminal-open.ts +10 -5
- package/src/hub/tool-request.ts +131 -0
- package/src/local/deny.ts +49 -0
- package/src/local/tools.ts +4 -2
- package/src/memory/brief.ts +5 -0
- package/src/memory/client.ts +5 -2
- 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
|
@@ -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/board.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ReviewerPoolReason } from "./reviewer-pool.ts";
|
|
|
11
11
|
import type { GitHubSource } from "./github-import.ts";
|
|
12
12
|
import type { Database } from "./db-schema.ts";
|
|
13
13
|
import { openHubDatabase, advanceHubDbSchema } from "./db-schema.ts";
|
|
14
|
+
import { controlWriteSpan } from "./control-write-trace.ts";
|
|
14
15
|
import { planningPatch, type PlanningFields, type Release, type ReleaseState } from "./planning.ts";
|
|
15
16
|
import { HUB, USER, type PeerId } from "./envelope.ts";
|
|
16
17
|
import type { SweepRecord } from "./task-sweep.ts";
|
|
@@ -18,6 +19,8 @@ import type { SdlcReceipt } from "./sdlc.ts";
|
|
|
18
19
|
import { parseReviewFindings, type ReviewFindingSet } from "./review-findings.ts";
|
|
19
20
|
import { parseDeliverableRecords, type DeliverableRecord } from "./deliverables.ts";
|
|
20
21
|
import type { CostPlan, ReviewCost } from "./cost-governance.ts";
|
|
22
|
+
import type { SpecRef, SpecTaskReport } from "./specs-task.ts";
|
|
23
|
+
export type { SpecAck, SpecRef, SpecTaskReport } from "./specs-task.ts";
|
|
21
24
|
import { campaignHash, parseResearchCampaign, parseResearchHandoff, type ResearchCampaignContract, type ResearchCampaignStatus, type ResearchUnit, type ResearchHandoffV1, type ResearchHandoffRecord } from "./research-campaign.ts";
|
|
22
25
|
|
|
23
26
|
export interface ResearchCampaignRecord {
|
|
@@ -133,7 +136,7 @@ export type TaskState = "backlog" | "proposed" | "in_progress" | "in_review" | "
|
|
|
133
136
|
export const isClosedTaskState = (state: string): boolean => state === "approved" || state === "cancelled";
|
|
134
137
|
export interface TaskOffer { envelopeId: string; owner: PeerId; assignmentFlowRevision: number; pullOnly?: boolean }
|
|
135
138
|
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;
|
|
139
|
+
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
140
|
export type TaskStallCode = (typeof TASK_STALL_CODES)[number];
|
|
138
141
|
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
142
|
|
|
@@ -201,6 +204,10 @@ export interface HistoryEntry {
|
|
|
201
204
|
sweep?: SweepRecord;
|
|
202
205
|
/** The PII screen did not clear this entry's note (#198): only the console reads it. */
|
|
203
206
|
withheld?: true;
|
|
207
|
+
/** Public, head-bound design-spec status; acknowledgement text is screened before this is written. */
|
|
208
|
+
specReport?: SpecTaskReport;
|
|
209
|
+
/** Internal identity for incomplete reports without a full commit SHA; never projected publicly. */
|
|
210
|
+
specRefBinding?: string;
|
|
204
211
|
/** Host evidence at done, never inferred from the owner's summary. */
|
|
205
212
|
noWrites?: true;
|
|
206
213
|
/** Host-recorded expected cost and exact review head, never narrative. */
|
|
@@ -277,6 +284,31 @@ function reviewerHistory(extra: ReviewerHistory): ReviewerHistory {
|
|
|
277
284
|
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
285
|
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
286
|
}
|
|
287
|
+
function specReportHistory(report: unknown, binding: unknown, event: string): Pick<HistoryEntry, "specReport" | "specRefBinding"> {
|
|
288
|
+
if (!report || typeof report !== "object" || Array.isArray(report) || !["done", "done (checking)"].includes(event)
|
|
289
|
+
|| typeof binding !== "string" || !/^[a-f0-9]{64}$/.test(binding)) throw new Error("invalid task spec report metadata");
|
|
290
|
+
const value = report as Record<string, unknown>;
|
|
291
|
+
if (Object.keys(value).some(key => !["head", "complete", "errors", "unchanged", "changed", "truncated", "reason"].includes(key))
|
|
292
|
+
|| typeof value.head !== "string" || value.head !== "" && !/^[a-f0-9]{40}$/.test(value.head)
|
|
293
|
+
|| value.head === "" && value.complete !== false || typeof value.complete !== "boolean"
|
|
294
|
+
|| !Number.isSafeInteger(value.errors) || (value.errors as number) < 0 || (value.errors as number) > 1_000_000
|
|
295
|
+
|| value.truncated !== undefined && value.truncated !== true
|
|
296
|
+
|| value.reason !== undefined && (typeof value.reason !== "string" || value.reason.length > 500 || /[\p{Cc}\p{Cf}]/u.test(value.reason))
|
|
297
|
+
|| !Array.isArray(value.unchanged) || value.unchanged.length > 20 || !Array.isArray(value.changed) || value.changed.length > 20) throw new Error("invalid task spec report metadata");
|
|
298
|
+
const ref = (raw: unknown): SpecRef => {
|
|
299
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("invalid task spec report metadata");
|
|
300
|
+
const row = raw as Record<string, unknown>;
|
|
301
|
+
if (Object.keys(row).some(key => !["id", "path", "title", "reason"].includes(key))
|
|
302
|
+
|| typeof row.id !== "string" || !row.id.trim() || row.id.length > 160 || /[\p{Cc}\p{Cf}]/u.test(row.id)
|
|
303
|
+
|| typeof row.path !== "string" || !row.path.trim() || row.path.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.path)
|
|
304
|
+
|| typeof row.title !== "string" || !row.title.trim() || row.title.length > 1000 || /[\p{Cc}\p{Cf}]/u.test(row.title)
|
|
305
|
+
|| 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");
|
|
306
|
+
return { id: row.id, path: row.path, title: row.title, ...(typeof row.reason === "string" ? { reason: row.reason } : {}) };
|
|
307
|
+
};
|
|
308
|
+
const specReport: SpecTaskReport = { head: value.head, complete: value.complete, errors: value.errors as number,
|
|
309
|
+
unchanged: value.unchanged.map(ref), changed: value.changed.map(ref), ...(value.truncated === true ? { truncated: true } : {}), ...(typeof value.reason === "string" ? { reason: value.reason } : {}) };
|
|
310
|
+
return { specReport, specRefBinding: binding };
|
|
311
|
+
}
|
|
280
312
|
export interface PlannerBinding { planner: PeerId | null; plannerReason: PlannerReason }
|
|
281
313
|
function plannerValue(value: unknown): PeerId | null {
|
|
282
314
|
if (value == null) return null;
|
|
@@ -400,11 +432,11 @@ export class Board {
|
|
|
400
432
|
|
|
401
433
|
/** Keep the generation and business write atomic without changing Bun's reported row count. */
|
|
402
434
|
private taskWrite<T extends { changes: number }>(write: () => T): T {
|
|
403
|
-
return this.db.transaction(() => {
|
|
435
|
+
return controlWriteSpan("board-write", () => this.db.transaction(() => {
|
|
404
436
|
const result = { ...write() };
|
|
405
437
|
if (result.changes) this.db.run("UPDATE board_status_revision SET revision=revision+1 WHERE id=1");
|
|
406
438
|
return result;
|
|
407
|
-
})();
|
|
439
|
+
})());
|
|
408
440
|
}
|
|
409
441
|
|
|
410
442
|
statusRevision(): number {
|
|
@@ -1362,6 +1394,26 @@ export class Board {
|
|
|
1362
1394
|
statusTasks(): Pick<Task, "id" | "state" | "owner" | "reviewer">[] {
|
|
1363
1395
|
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
1396
|
}
|
|
1397
|
+
/** Fresh privacy classification inputs only; no task history, title, detail or plan decoding. */
|
|
1398
|
+
peerTaskSignals(peer: string): Pick<Task, "state" | "signals">[] {
|
|
1399
|
+
const rows = this.db.query("SELECT state, signals FROM tasks WHERE owner=? OR reviewer=?").all(peer, peer) as { state: unknown; signals: unknown }[];
|
|
1400
|
+
return rows.map(row => {
|
|
1401
|
+
const signals: unknown = JSON.parse(String(row.signals));
|
|
1402
|
+
if (typeof row.state !== "string" || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
|
|
1403
|
+
return { state: row.state as TaskState, signals: signals as string[] };
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
/** Fresh privacy facts for a bounded explicit reference set; no task history or text decoding. */
|
|
1407
|
+
taskPrivacySignals(ids: readonly number[]): Pick<Task, "id" | "signals">[] {
|
|
1408
|
+
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");
|
|
1409
|
+
if (!ids.length) return [];
|
|
1410
|
+
const rows = this.db.query(`SELECT id, signals FROM tasks WHERE id IN (${ids.map(() => "?").join(",")})`).all(...ids) as { id: unknown; signals: unknown }[];
|
|
1411
|
+
return rows.map(row => {
|
|
1412
|
+
const signals: unknown = JSON.parse(String(row.signals));
|
|
1413
|
+
if (!Number.isSafeInteger(row.id) || !Array.isArray(signals) || signals.some(signal => typeof signal !== "string")) throw new Error("invalid task privacy projection");
|
|
1414
|
+
return { id: row.id as number, signals: signals as string[] };
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1365
1417
|
statusReservations(): string[] {
|
|
1366
1418
|
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
1419
|
}
|
|
@@ -1454,7 +1506,7 @@ export class Board {
|
|
|
1454
1506
|
|
|
1455
1507
|
/** The only way a task changes. Validates the move, records who did what, returns the new row. */
|
|
1456
1508
|
/** 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 {
|
|
1509
|
+
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
1510
|
const task = this.get(id);
|
|
1459
1511
|
if (!task) throw new Error(`no task #${id}`);
|
|
1460
1512
|
if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
|
|
@@ -1480,6 +1532,8 @@ export class Board {
|
|
|
1480
1532
|
const plannerHistory = Object.hasOwn(patch, "planner") || extra.plannerReason !== undefined ? plannerMetadata(planner, extra.plannerReason) : {};
|
|
1481
1533
|
const authorHistory = Object.hasOwn(extra, "planAuthor") ? { planAuthor: plannerValue(extra.planAuthor) } : {};
|
|
1482
1534
|
if (extra.selfReview !== undefined && extra.selfReview !== true) throw new Error("invalid self-review metadata");
|
|
1535
|
+
const specMetadata = extra.specReport !== undefined || extra.specRefBinding !== undefined
|
|
1536
|
+
? specReportHistory(extra.specReport, extra.specRefBinding, event) : {};
|
|
1483
1537
|
if (extra.conductorManual !== undefined && (extra.conductorManual !== true || by === "user" || by === "hub" || extra.reason !== "manual"
|
|
1484
1538
|
|| !["assigned", "reassigned", "reserved", "unassigned", "reviewer changed"].includes(event))) throw new Error("invalid conductor manual metadata");
|
|
1485
1539
|
if (extra.invocation && (by !== HUB || event !== "invocation")) throw new Error("invocation audit is host-only");
|
|
@@ -1533,7 +1587,7 @@ export class Board {
|
|
|
1533
1587
|
&& priorHead !== undefined && /^[a-f0-9]{40}$/.test(priorHead);
|
|
1534
1588
|
const previousHistory = backfill ? task.history.map((entry, index) => index === priorApproval && !entry.reviewedHead
|
|
1535
1589
|
? { ...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 } : {}) }];
|
|
1590
|
+
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
1591
|
this.taskWrite(() => {
|
|
1538
1592
|
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
1593
|
.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,8 +1,16 @@
|
|
|
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";
|
|
7
|
+
import { ControlWriteTelemetry, controlWriteMetadata, controlWriteSpan } from "./control-write-trace.ts";
|
|
8
|
+
|
|
9
|
+
// The client has no authoritative task privacy view. Keep kind/timing only, never its args or task id.
|
|
10
|
+
const clientWriteTelemetry = new ControlWriteTelemetry({ emit: line => {
|
|
11
|
+
if (process.env.AHUB_CONTROL_WRITE_TIMING === "1") console.error(`[agent-hub] ${line}`);
|
|
12
|
+
} });
|
|
13
|
+
const publicClientTiming = () => true;
|
|
6
14
|
|
|
7
15
|
export function stateDirFor(cwd: string): string {
|
|
8
16
|
return projectContext(cwd).stateDir;
|
|
@@ -12,7 +20,8 @@ export function stateDirFor(cwd: string): string {
|
|
|
12
20
|
export const PROTOCOL = 17;
|
|
13
21
|
/** The descriptor's legacy protocol field remains this floor for previous published clients. */
|
|
14
22
|
export const PROTOCOL_MIN = 16;
|
|
15
|
-
|
|
23
|
+
// Literal metadata is read without executing a recovery target; consistency is pinned in tests.
|
|
24
|
+
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
25
|
/** Protocols a current coordinator may authenticate while upgrading a running source. */
|
|
17
26
|
export const RECOVERY_SOURCE_PROTOCOLS = [9, 10, 11, 12, 13, 14, 15, 16, PROTOCOL] as const;
|
|
18
27
|
|
|
@@ -219,22 +228,52 @@ export class ControlClient {
|
|
|
219
228
|
|
|
220
229
|
/** `timeoutMs`: for calls a person waits on. The hub has its own budget; this one only makes sure the CLI never hangs. */
|
|
221
230
|
request(msg: Record<string, unknown>, timeoutMs = 30_000, signal?: AbortSignal): Promise<any> {
|
|
231
|
+
if (process.env.AHUB_CONTROL_WRITE_TIMING !== "1") return this.requestBody(msg, timeoutMs, signal);
|
|
232
|
+
const metadata = controlWriteMetadata(msg);
|
|
233
|
+
if (!metadata) return this.requestBody(msg, timeoutMs, signal);
|
|
234
|
+
return clientWriteTelemetry.run({ kind: metadata.kind }, publicClientTiming, () => controlWriteSpan("client-wait", () =>
|
|
235
|
+
this.requestBody(msg, timeoutMs, signal)));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private requestBody(msg: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<any> {
|
|
222
239
|
if (this.ws.readyState !== WebSocket.OPEN || this.closing && msg.t !== "request_cancel") return Promise.resolve({ ok: false, error: "hub connection is not open" });
|
|
223
240
|
const invocation = msg.t === "task" && msg.op === "hub_invoke";
|
|
241
|
+
const operation = toolWriteOperation(msg), write = operation !== undefined;
|
|
242
|
+
const keyedWrite = write && operation !== "hub_delivery_done";
|
|
243
|
+
if (keyedWrite && msg.args && typeof msg.args === "object" && !Array.isArray(msg.args) && Object.hasOwn(msg.args, "request_id")) {
|
|
244
|
+
const { request_id: key, ...args } = msg.args as Record<string, unknown>;
|
|
245
|
+
if (typeof key !== "string" || !key || key.length > 256 || /[\p{Cc}\p{Cf}]/u.test(key)
|
|
246
|
+
|| msg.requestId !== undefined && msg.requestId !== key) {
|
|
247
|
+
return Promise.resolve(toolWriteRefusedReply(msg, undefined, "request_id must be a non-empty echoed request key and agree with requestId"));
|
|
248
|
+
}
|
|
249
|
+
msg = { ...msg, args, requestId: key };
|
|
250
|
+
}
|
|
251
|
+
if (keyedWrite && msg.requestId !== undefined && !this.features.includes(TOOL_WRITE_FEATURE)) {
|
|
252
|
+
return Promise.resolve(toolWriteRefusedReply(msg, typeof msg.requestId === "string" ? msg.requestId : undefined,
|
|
253
|
+
"this daemon does not advertise replay protection; nothing was resubmitted"));
|
|
254
|
+
}
|
|
255
|
+
let wire = msg;
|
|
256
|
+
if (keyedWrite && this.features.includes(TOOL_WRITE_FEATURE) && msg.requestId === undefined) {
|
|
257
|
+
try { wire = { ...msg, requestId: newToolRequestId(this.instanceId ?? "") }; }
|
|
258
|
+
catch { return Promise.resolve({ ok: false, error: "write request identity is unavailable; inspect ahub status before retrying" }); }
|
|
259
|
+
}
|
|
260
|
+
const requestId = typeof wire.requestId === "string" ? wire.requestId : undefined;
|
|
261
|
+
const uncertain = (cause: string) => toolWriteUnknownReply(wire, requestId,
|
|
262
|
+
this.features.includes(TOOL_WRITE_FEATURE) ? cause : `${cause}; this daemon does not advertise replay protection`);
|
|
224
263
|
timeoutMs = controlRequestTimeout(msg, timeoutMs);
|
|
225
264
|
if (signal?.aborted) return Promise.resolve({ ok: false, error: "hub request cancelled before sending" });
|
|
226
265
|
const rid = this.nextRid++;
|
|
227
266
|
return new Promise((resolve) => {
|
|
228
267
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
229
268
|
const done = (reply: any) => {
|
|
230
|
-
this.pending.delete(rid); this.invocations.delete(rid); clearTimeout(timer); signal?.removeEventListener("abort", aborted); resolve(reply);
|
|
269
|
+
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
270
|
};
|
|
232
271
|
const abandon = (error: string) => {
|
|
233
272
|
if (!this.pending.has(rid)) return;
|
|
234
273
|
if (invocation && this.ws.readyState === WebSocket.OPEN) {
|
|
235
274
|
try { this.ws.send(JSON.stringify({ t: "request_cancel", request: rid })); } catch { /* Socket close also withdraws admission. */ }
|
|
236
275
|
}
|
|
237
|
-
done({ ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
|
|
276
|
+
done(write ? uncertain(error) : { ok: false, error: invocation ? `${error}; invocation admission unconfirmed, inspect before retrying` : error });
|
|
238
277
|
};
|
|
239
278
|
const aborted = () => abandon("hub request cancelled");
|
|
240
279
|
this.pending.set(rid, done);
|
|
@@ -242,7 +281,7 @@ export class ControlClient {
|
|
|
242
281
|
signal?.addEventListener("abort", aborted, { once: true });
|
|
243
282
|
if (signal?.aborted) { aborted(); return; }
|
|
244
283
|
timer = timeoutMs ? setTimeout(() => abandon(`no answer from the hub within ${Math.round(timeoutMs / 1000)} s`), timeoutMs) : undefined;
|
|
245
|
-
try { this.ws.send(JSON.stringify({ ...
|
|
284
|
+
try { controlWriteSpan("client-send", () => this.ws.send(JSON.stringify({ ...wire, rid }))); }
|
|
246
285
|
catch { abandon("hub request could not be sent"); }
|
|
247
286
|
});
|
|
248
287
|
}
|