@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
package/src/hub/daemon.ts
CHANGED
|
@@ -38,11 +38,14 @@ import { isDenied, sbplString } from "../local/deny.ts";
|
|
|
38
38
|
import { hostShellPath } from "./host-executable.ts";
|
|
39
39
|
import { supersededDeliveryReason, deliveryTaskContext } from "./delivery-superseded.ts";
|
|
40
40
|
import { renderHeldHint } from "./delivery-hints.ts";
|
|
41
|
+
import { compactRow, pageTasks, parseListQuery } from "./task-list-page.ts";
|
|
41
42
|
import { correctionInput, MemoryTelemetry } from "./memory-candidates.ts";
|
|
42
43
|
import { MemoryObservations } from "./memory-observations.ts";
|
|
43
44
|
import { createStalenessProbe } from "./memory-staleness.ts";
|
|
44
45
|
import { matchingPlanFails, planGuardNodes, taskMemoryQueryNodes } from "./memory-plan-guard.ts";
|
|
45
46
|
import { planningWipLimits } from "./planning.ts";
|
|
47
|
+
import { ToolWriteRegistry, toolWriteOperation, toolWriteUnknownReply } from "./tool-request.ts";
|
|
48
|
+
import { ControlWriteTelemetry, controlWriteMetadata, controlWriteSpan } from "./control-write-trace.ts";
|
|
46
49
|
import { projectedDiff } from "./projected-diff.ts";
|
|
47
50
|
import { listenWithoutPortCollision } from "./listen.ts";
|
|
48
51
|
import { needs, type NeedsInput } from "../ui/needs.ts";
|
|
@@ -54,7 +57,7 @@ import { buildReviewBrief, withCodeSymbols } from "./review-brief.ts";
|
|
|
54
57
|
import { profileName, projectProfile, resolveProfile, mergeProfileOverrides, type ProfileName } from "./profiles.ts";
|
|
55
58
|
import { catalogAssignments, loadRoleCatalog, selectedRoles, roleIdentity } from "./roles.ts";
|
|
56
59
|
import { ActivityRing } from "./activity.ts";
|
|
57
|
-
import { JcodeOptionMismatch, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
|
|
60
|
+
import { JcodeOptionMismatch, JcodeTuiRefusal, isJcodePeer, jcodeVersionAsync, type JcodePeer } from "../adapters/jcode.ts";
|
|
58
61
|
import { VERSION_REFUSAL, configuredCommands, describe, isCompatId } from "../adapters/compat.ts";
|
|
59
62
|
import { agentVersionsAsync, type AgentVersionRow } from "../cli/agents.ts";
|
|
60
63
|
import { driftedAgents, noticeDrift, type DriftedAgent } from "./agent-drift.ts";
|
|
@@ -104,6 +107,7 @@ import { conductorRecoveryBinding } from "../cli/conductor-recovery.ts";
|
|
|
104
107
|
import { createConfigEditor } from "./config-edit-host.ts";
|
|
105
108
|
import { readEditable } from "./config-edit.ts";
|
|
106
109
|
import { eventLog, readEvents, type RoleIdentity } from "./events.ts";
|
|
110
|
+
import { SpecTaskInspector } from "./specs-task.ts";
|
|
107
111
|
import { KimiUsage, kimiSeedRows } from "./kimi-usage.ts";
|
|
108
112
|
import { TokenWindow } from "./token-window.ts";
|
|
109
113
|
import { budgetNotes, type BudgetPeerNote } from "./budget-notes.ts";
|
|
@@ -171,6 +175,7 @@ import { autonomyPreset, autonomyEnvironment, planAutonomy, autonomyRemovalKeys,
|
|
|
171
175
|
import { readAutonomyDefaults } from "./autonomy-defaults.ts";
|
|
172
176
|
import { AUTONOMY_UNDO, autonomyOwnershipDigest, autonomyUndoKeys, writeAutonomyPreset, LEARN_CHECKLIST, NEVER_EDITABLE, projectSettings, checkSettingValue, ignoredLine, pendingUndo, pendingUndoIdentity, readOverlay, restoredValue, routingCandidate, storedLocally, sharedValue, settingDef as instanceSettingDef, settingRefusal, settingRows, settingText, undoSetting, valueAt, writeConfigSetting, writeRoutingSetting, type SettingAuthority, type SettingSource, type SettingDef, type SettingRow, type SettingValue, type UndoIdentity } from "./settings.ts";
|
|
173
177
|
import { githubOriginRepository, githubRemoteRepository } from "./task-repository.ts";
|
|
178
|
+
import { TaskWorktrees, originVerdict } from "./task-worktree.ts";
|
|
174
179
|
import { Bus } from "./bus.ts";
|
|
175
180
|
import { attribute, deliveryTask, type TaskAttribution } from "./attribution.ts";
|
|
176
181
|
import { DeliveryJournal } from "./delivery-journal.ts";
|
|
@@ -193,7 +198,7 @@ import { projectChain } from "@staix/agent-mem";
|
|
|
193
198
|
import { recallFor } from "../memory/recall.ts";
|
|
194
199
|
import { conflictsOf } from "./conflicts.ts";
|
|
195
200
|
import { Facts, FACTS_PREFIX, type FactScope } from "./facts.ts";
|
|
196
|
-
import { crashPlan, lossNotice, readSessions, removeSessions, writeSessions, type SessionsFile } from "./crash.ts";
|
|
201
|
+
import { crashPlan, lossNotice, openCrashNotices, readSessions, removeSessions, writeSessions, type CrashNotice, type SessionsFile } from "./crash.ts";
|
|
197
202
|
import type { JournalDelivery } from "./delivery-journal.ts";
|
|
198
203
|
import { DEFAULT_LIMITS, Limiter, PROJECT_LIMITS, type LimitsConfig } from "./limits.ts";
|
|
199
204
|
import { soleWorktree, branchHeads, changedPaths, dirtySubmodules, submodulePaths, knownChangedPaths, hasTree, headState, headMoveCursor, incomingHeadPaths, repoOf, snapshot, SnapshotBudgetExceeded, budgetNow, Turns, type SnapshotBudget, type HeadMoveCursor, type Repo, type TurnRecord } from "./snapshots.ts";
|
|
@@ -218,6 +223,7 @@ import { SourceRegistry } from "./sources.ts";
|
|
|
218
223
|
import { MEMORY_READ_TOOLS, memoryRead, validateMemoryRead, sourceRead, validateSourceRead } from "./read-tools.ts";
|
|
219
224
|
import { nativeRecall } from "../memory/recall.ts";
|
|
220
225
|
import { promotionScreen, withoutPii, mentionsPrivatePath } from "./memory-policy.ts";
|
|
226
|
+
import { DEFAULT_SPECS_CONFIG, parseSpecsConfig, type SpecsConfig } from "./specs-config.ts";
|
|
221
227
|
|
|
222
228
|
export interface HubConfig {
|
|
223
229
|
adapter_modules?: readonly AdapterModuleDeclaration[];
|
|
@@ -260,6 +266,7 @@ export interface HubConfig {
|
|
|
260
266
|
text_projection?: ProjectionConfig;
|
|
261
267
|
/** #510: machine-local folders of plain notes the hub may index, read-only; vault folders are refused when the index reads them. */
|
|
262
268
|
corpus: { paths: string[] };
|
|
269
|
+
specs: SpecsConfig;
|
|
263
270
|
catalog_roles?: Record<string, string[]>;
|
|
264
271
|
conductor: { feed: "own" | "all" | "off"; approval_wait_s: number };
|
|
265
272
|
budget: BudgetConfig;
|
|
@@ -352,6 +359,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
352
359
|
schedules: [],
|
|
353
360
|
folder_triggers: [],
|
|
354
361
|
corpus: { paths: [] },
|
|
362
|
+
specs: { ...DEFAULT_SPECS_CONFIG, roots: [] },
|
|
355
363
|
checks: { timeout_s: 600 },
|
|
356
364
|
// Off here like approvals.notify, so tests (whose cwd is this repository) write no objects; a project's config turns it on.
|
|
357
365
|
snapshots: { enabled: false, keep: 20 },
|
|
@@ -370,14 +378,14 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
370
378
|
export { stateDirFor };
|
|
371
379
|
|
|
372
380
|
/** Ids an external process may not claim: the console user and the adapters the daemon runs itself. */
|
|
373
|
-
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status"]);
|
|
381
|
+
const READ_ONLY_HUB_TOOLS = new Set(["hub_capabilities", "hub_inbox", "hub_send", "hub_delivery_done", "hub_review", "hub_task_show", "hub_task_list", "hub_study_show", "hub_experiment_show", "hub_memory_search", "hub_memory_show", "hub_memory_graph", "hub_source_read", "hub_status", "hub_specs"]);
|
|
374
382
|
const BASE_RESERVED_IDS = new Set<string>([USER, ...SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.attach?.externalHello !== true), HUB, DIGEST]);
|
|
375
383
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
376
384
|
|
|
377
385
|
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
378
386
|
const CONFIG_FILES = INSTANCE_CONFIG_FILES;
|
|
379
387
|
export interface ConfigLayer { name: typeof CONFIG_FILES[number]; raw: Record<string, any> }
|
|
380
|
-
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
|
|
388
|
+
const CONFIG_BLOCKS = ["action_policy", "memory", "roster", "roles", "catalog_roles", "pii", "data", "conductor", "budget", "context", "inference", "omniroute", ...NATIVE_CONFIG_BLOCKS, "approvals", "tasks", "task_sweep", "bus", "adapter_timing", "model_relay", "console", "corpus", "specs", "checks", "snapshots", "research", "bench", "limits", "review", "recovery", "capabilities", "mlx", "permission_modes", "peers", "terminal", "start", "pet"];
|
|
381
389
|
|
|
382
390
|
/** Older targets parse raw keys before placement/trust checks, so even an empty or refused declaration needs the floor. */
|
|
383
391
|
function rawResearchRunnersPresent(cwd: string): boolean {
|
|
@@ -483,6 +491,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
483
491
|
resolveProfile(file.profile, file.profile_overrides);
|
|
484
492
|
projectionConfig(file.text_projection);
|
|
485
493
|
corpusConfig(file.corpus);
|
|
494
|
+
if (file.specs !== undefined) parseSpecsConfig(file.specs);
|
|
486
495
|
queueBackpressurePolicy(file.tasks?.queue_backpressure);
|
|
487
496
|
validateOperationalConfig(file);
|
|
488
497
|
for (const [block, key] of [["pii", "paths"], ["data", "readonly"]] as const) {
|
|
@@ -599,6 +608,7 @@ export function loadConfig(cwd: string, layers?: readonly ConfigLayer[], routing
|
|
|
599
608
|
schedules: schedulesConfig(file.schedules),
|
|
600
609
|
folder_triggers: foldersConfig(file.folder_triggers),
|
|
601
610
|
corpus: corpusConfig(file.corpus),
|
|
611
|
+
specs: parseSpecsConfig(file.specs),
|
|
602
612
|
checks: { ...DEFAULT_CONFIG.checks, ...file.checks, runner: file.checks?.runner === "remote" ? "remote" : file.checks?.runner === "container" ? "container" : undefined,
|
|
603
613
|
...(file.checks?.remote !== undefined || file.checks?.runner === "remote" ? { remote: remoteRunnerConfig(file.checks?.remote) } : {}) },
|
|
604
614
|
snapshots: { ...DEFAULT_CONFIG.snapshots, enabled: true, ...file.snapshots },
|
|
@@ -656,6 +666,8 @@ export interface DaemonOptions {
|
|
|
656
666
|
releaseHostAdmission?: (cwd: string) => Promise<ReleaseHostAdmission>;
|
|
657
667
|
/** Host-only (#285): Kimi's data home for its wire-log usage reader; default `KIMI_CODE_HOME`, else `~/.kimi-code`. Never read from config or tools. */
|
|
658
668
|
kimiHome?: string;
|
|
669
|
+
/** Host-only replay-window test ceiling (1..512); never read from project config or a tool. */
|
|
670
|
+
toolWriteReplayMaxEntries?: number;
|
|
659
671
|
/** Host-only evidence seam; never supplied by project config or control messages. */
|
|
660
672
|
releaseDocEvidence?: (root: string) => Promise<DocEvidence | undefined>;
|
|
661
673
|
projectId?: string;
|
|
@@ -752,6 +764,9 @@ const RESOLUTION_CAUSES: [RegExp, string][] = [
|
|
|
752
764
|
[/active delivery/i, "active delivery: it was handed to the peer and is not settled yet; wait for it, or resolve it once it is needs_review"],
|
|
753
765
|
[/peer busy/i, "peer busy: the peer is working, and only a queued delivery can be discarded meanwhile"],
|
|
754
766
|
[/stale delivery revision/i, "stale revision: the delivery changed since you read it; run ahub queue show again"],
|
|
767
|
+
// #1096: a row that already carries a resolution is settled history, not a stale read. `retry` leaves its parent in `failed` for good because the redelivery is a separate row.
|
|
768
|
+
[/already resolved by retry/i, "already resolved by retry: this failed row is settled history, its redelivery is a separate delivery (ahub queue list shows it); nothing is left to do for this row"],
|
|
769
|
+
[/already resolved by (completed|discard)/i, "already resolved: this delivery was already settled by a person or the conductor; nothing is left to do for this row"],
|
|
755
770
|
[/unknown delivery|delivery not found/i, "unknown delivery: no such delivery id"],
|
|
756
771
|
[/already terminal|already resolved/i, "already resolved: the delivery is settled"],
|
|
757
772
|
[/already scheduled/i, "queued delivery is already scheduled: discard it, or wait for its turn"],
|
|
@@ -778,6 +793,7 @@ function capturedRecoverySourceDigest(): string | undefined {
|
|
|
778
793
|
export async function startDaemon(opts: DaemonOptions) {
|
|
779
794
|
// Refuse unknown formats before logs, session cleanup, module factories or any writable store is opened.
|
|
780
795
|
assertHubDbCompatible(join(opts.stateDir, "hub.db"));
|
|
796
|
+
if (opts.toolWriteReplayMaxEntries !== undefined && (!Number.isSafeInteger(opts.toolWriteReplayMaxEntries) || opts.toolWriteReplayMaxEntries < 1 || opts.toolWriteReplayMaxEntries > 512)) throw new Error("invalid host tool write replay capacity");
|
|
781
797
|
const resolveExecutionCwd = (peer: string, currentDeclarations: InstancesConfig | undefined, currentRoles: readonly string[], catalog: ReturnType<typeof createProjectAdapterCatalog>): string =>
|
|
782
798
|
resolveInstanceExecutionCwd(opts.cwd, peer, currentDeclarations, currentRoles, catalog, undefined, undefined, opts.instanceExecutionGit);
|
|
783
799
|
const projectId = opts.projectId ?? randomUUID();
|
|
@@ -1194,7 +1210,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1194
1210
|
let parsed: any;
|
|
1195
1211
|
try {
|
|
1196
1212
|
parsed = JSON.parse(String(data));
|
|
1197
|
-
|
|
1213
|
+
onControlMessage(sock, parsed);
|
|
1198
1214
|
} catch (e) {
|
|
1199
1215
|
if (isDatabaseBusy(e) && sock.data.authed && Number.isSafeInteger(parsed?.rid)) {
|
|
1200
1216
|
const busy = new RecoveryDatabaseBusy();
|
|
@@ -1268,8 +1284,12 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1268
1284
|
const continuityKind = ADAPTERS.find(adapter => adapter.start.daemonPort === "pi-rpc")!.kind;
|
|
1269
1285
|
const piAutoStart = () => !!autoStartAdapter && onRoster(autoStartAdapter.kind) && config.pi.auto_start;
|
|
1270
1286
|
const startedAt = Date.now();
|
|
1271
|
-
/** What crash recovery did or asks the user to do, for `ahub status
|
|
1272
|
-
const
|
|
1287
|
+
/** What crash recovery did or asks the user to do, for `ahub status` (#1082: `crashView` shows the lines that still stand). */
|
|
1288
|
+
const crashNotices: CrashNotice[] = [];
|
|
1289
|
+
/** A crash was reported this run. It never clears: the Jcode headless hint (#1085) has its own end (`tuiServed`), not the notices'. */
|
|
1290
|
+
let crashReported = false;
|
|
1291
|
+
/** The deliveries the crash left in needs_review, by id; filled below once the journal is open. */
|
|
1292
|
+
const crashLostIds = new Set<string>();
|
|
1273
1293
|
|
|
1274
1294
|
// The hub's own model calls (digest condensation, task triage) are wired below, once the gateway client exists.
|
|
1275
1295
|
let inference: Inference | undefined;
|
|
@@ -1279,7 +1299,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1279
1299
|
const journal = new DeliveryJournal({ file: join(opts.stateDir, "hub.db"), projectRoot: opts.cwd, projectId, instanceId, operationId: recoveryOperation, terminalHistoryLimit });
|
|
1280
1300
|
// What the crash left in flight, per recipient: opening the journal just marked these needs_review.
|
|
1281
1301
|
const lost = new Map<PeerId, JournalDelivery[]>();
|
|
1282
|
-
if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]);
|
|
1302
|
+
if (crashed) for (const d of journal.list()) if (d.state === "needs_review" && d.reason === "daemon stopped during delivery" && d.updatedAt >= startedAt) { lost.set(d.peer, [...(lost.get(d.peer) ?? []), d]); crashLostIds.add(d.id); }
|
|
1283
1303
|
// Capabilities (issue #39): enforced here and in taskOp, never by role text alone. Unlisted peers keep everything.
|
|
1284
1304
|
// A listed peer whose value is not a list gets nothing: whoever listed it meant to narrow it.
|
|
1285
1305
|
const may = (peer: PeerId, cap: "propose" | "assign" | "remember" | "memory_correct" | "memory_read" | "important" | "spec_read" | "check_run" | "invoke"): boolean => {
|
|
@@ -1700,10 +1720,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1700
1720
|
for (const t of board.list("in_progress")) if (!tasks.isPii(t)) renewLeases(t);
|
|
1701
1721
|
}
|
|
1702
1722
|
let syncRun: Promise<void> | undefined;
|
|
1723
|
+
let controlWriteTelemetry: ControlWriteTelemetry | undefined;
|
|
1703
1724
|
/** Machine sync, when a person set a remote; one log line per failure, never a blocked turn. Stopping aborts and awaits it. */
|
|
1704
1725
|
function syncNow(): Promise<void> {
|
|
1705
1726
|
if (!memoryStore || syncRun || stopping) return syncRun ?? Promise.resolve();
|
|
1706
|
-
|
|
1727
|
+
const run = async () => {
|
|
1707
1728
|
try {
|
|
1708
1729
|
checkMemory();
|
|
1709
1730
|
const r = await (opts.memorySync ?? syncMemory)(memoryStore!, { signal: syncStop.signal });
|
|
@@ -1711,7 +1732,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1711
1732
|
else if (r.warning) log(`hub memory sync: ${r.warning}`);
|
|
1712
1733
|
} catch { /* A stale binding disables native memory, never the bus. */ }
|
|
1713
1734
|
finally { syncRun = undefined; }
|
|
1714
|
-
}
|
|
1735
|
+
};
|
|
1736
|
+
syncRun = controlWriteTelemetry ? controlWriteTelemetry.observe("memory", () => !stopping, run) : run();
|
|
1715
1737
|
return syncRun;
|
|
1716
1738
|
}
|
|
1717
1739
|
|
|
@@ -2029,12 +2051,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2029
2051
|
}).finally(() => { refreshingRunner = undefined; });
|
|
2030
2052
|
return refreshingRunner;
|
|
2031
2053
|
};
|
|
2054
|
+
// #1115: the hub makes, commits and clears the task worktree of a hub-sandboxed peer. The sandbox profile is unchanged; the record file lives in the hub's state dir, which no sandbox root contains.
|
|
2055
|
+
const taskWorktree = new TaskWorktrees({
|
|
2056
|
+
root: opts.cwd, stateFile: join(opts.stateDir, "task-worktrees.json"),
|
|
2057
|
+
applies: peer => definitionOf(peer)?.trust.hubSandboxed === true,
|
|
2058
|
+
deny: () => config.local.deny, protectInstructions: () => config.protect_instruction_files,
|
|
2059
|
+
pushAllowed: urls => originVerdict(shellProjectRepository, urls, url => githubRemoteRepository(url)),
|
|
2060
|
+
});
|
|
2032
2061
|
const peerCapabilities = () => {
|
|
2033
2062
|
// Keep the last confirmed capability during a bounded, single-flight refresh; replace it only on a result.
|
|
2034
2063
|
if (Date.now() - runnerStateAt >= 60_000) void refreshRunner();
|
|
2035
2064
|
return Object.fromEntries([...new Set([...SUPPORTED_PEERS, ...bus.peers.keys()])].map(peer => {
|
|
2036
|
-
if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true }];
|
|
2037
|
-
|
|
2065
|
+
if (!definitionOf(peer)?.trust.hubSandboxed) return [peer, { tests: true, spec: true, network: true, worktree: true }];
|
|
2066
|
+
// #1081: Pi and the local worker run their shell under one sandbox network setting (`local.bash_network`): false is no network at all, an egress proxy or "direct" is some.
|
|
2067
|
+
const network = sandboxNetwork !== false;
|
|
2068
|
+
// A hub sandbox never lets its shell run `git worktree add` (#270: it writes under the git common dir), so the hub makes the task worktree for such a peer (#1115): the capability is whether it can here.
|
|
2069
|
+
const hubWorktree = taskWorktree.available();
|
|
2070
|
+
return [peer, { tests: runnerState.available && may(peer, "check_run"), spec: specState.available && may(peer, "spec_read"), network, worktree: hubWorktree.ok,
|
|
2071
|
+
...(hubWorktree.ok ? {} : { worktreeReason: `the hub cannot make a task worktree for it: ${hubWorktree.reason}; ${hubWorktree.next} (#1115)` }),
|
|
2072
|
+
...(network ? {} : { networkReason: "local.bash_network is off, so its shell has no network (ahub settings local.bash_network)" }),
|
|
2038
2073
|
testsReason: !may(peer, "check_run") ? `capabilities.${peer}` : `runner unavailable: ${runnerState.reason ?? "unknown"}`,
|
|
2039
2074
|
specReason: !may(peer, "spec_read") ? `capabilities.${peer}` : specState.reason ?? "gh unauthenticated" }];
|
|
2040
2075
|
}));
|
|
@@ -2077,6 +2112,27 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2077
2112
|
return () => token!==undefined && binding!==undefined && bus.peers.get(by)===peer && peer?.reviewToken(task)===token && tasks.reviewBindingCurrent(by,task,binding)
|
|
2078
2113
|
? undefined : `no review of task #${task} is running for ${by} at its bound head and revision`;
|
|
2079
2114
|
};
|
|
2115
|
+
type SpecRootSnapshot = { peer: PeerId; root: string; executionRoot: string; verifiedRoot?: string; owner?: PeerAdapter; attached?: PeerAdapter; cwd?: string; socket?: Sock; launchId?: string; generation?: string | number; repo?: string };
|
|
2116
|
+
const specInspectors = new Map<string, SpecTaskInspector>();
|
|
2117
|
+
const specInspectorAt = (root: string): SpecTaskInspector => {
|
|
2118
|
+
const cached = specInspectors.get(root);
|
|
2119
|
+
if (cached) { specInspectors.delete(root); specInspectors.set(root, cached); return cached; }
|
|
2120
|
+
const created = new SpecTaskInspector(root, () => config.specs);
|
|
2121
|
+
specInspectors.set(root, created);
|
|
2122
|
+
if (specInspectors.size > 8) specInspectors.delete(specInspectors.keys().next().value!);
|
|
2123
|
+
return created;
|
|
2124
|
+
};
|
|
2125
|
+
const specRootSnapshot = (task: Task): SpecRootSnapshot => {
|
|
2126
|
+
const peer = task.owner ?? USER, binding = executionOwners.get(peer), execution = executionRoots.get(peer) ?? opts.cwd;
|
|
2127
|
+
const root = binding?.socket && !currentExecutionOwner(peer) ? opts.cwd : execution;
|
|
2128
|
+
const verifiedRoot = ownerWriteRoot(peer), attached = bus.peers.get(peer);
|
|
2129
|
+
return { peer, root, executionRoot: execution, ...(verifiedRoot ? { verifiedRoot } : {}), ...(binding ? { owner: binding.owner, cwd: binding.cwd, ...(binding.socket ? { socket: binding.socket } : {}), ...(binding.launchId ? { launchId: binding.launchId } : {}), ...(binding.generation === undefined ? {} : { generation: binding.generation }) } : {}), ...(attached ? { attached } : {}), ...(task.refs.repo ? { repo: task.refs.repo } : {}) };
|
|
2130
|
+
};
|
|
2131
|
+
const sameSpecRoot = (left: SpecRootSnapshot, right: SpecRootSnapshot): boolean => left.peer === right.peer && left.root === right.root
|
|
2132
|
+
&& left.executionRoot === right.executionRoot && left.verifiedRoot === right.verifiedRoot && left.owner === right.owner && left.attached === right.attached
|
|
2133
|
+
&& left.cwd === right.cwd && left.socket === right.socket && left.launchId === right.launchId && left.generation === right.generation && left.repo === right.repo;
|
|
2134
|
+
const projectSpecs = specInspectorAt(opts.cwd);
|
|
2135
|
+
let specsMissingBlockHintShown = false;
|
|
2080
2136
|
const tasks: Tasks = new Tasks({
|
|
2081
2137
|
releaseWindow,
|
|
2082
2138
|
researchRunners: () => {
|
|
@@ -2085,7 +2141,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2085
2141
|
if (why || configSourceTexts(opts.cwd)["config.local.json"] === null) throw new Error(`research.runners source trust unavailable${why ? `: ${why}` : ""}`);
|
|
2086
2142
|
return parseResearchRunners(config.research.runners);
|
|
2087
2143
|
},
|
|
2088
|
-
capabilities: peerCapabilities, repository: memoryRepo,
|
|
2144
|
+
capabilities: peerCapabilities, taskWorktree, repository: memoryRepo,
|
|
2089
2145
|
contextAdmission: () => ({
|
|
2090
2146
|
policy: { max_used: config.context.max_assignment_used ?? 0, stale_min: config.context.stale_min },
|
|
2091
2147
|
peers: (config.context.max_assignment_used ?? 0) > 0 ? Object.fromEntries(bus.knownPeers().filter(peer => definitionOf(peer)?.budget?.context).map(peer => [peer,
|
|
@@ -2163,6 +2219,20 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2163
2219
|
const brief = buildReviewBrief(root, task.refs.commit, { privatePath: path => isDenied(path, config.local.deny) || matchesPolicy(root, path, config.pii?.paths ?? [], true), nameable: tasks.nameable });
|
|
2164
2220
|
return "unavailable" in brief ? brief : withCodeSymbols(brief, reviewSymbols.get(JSON.stringify([root, brief.head, brief.mergeBase])) ?? { state: "unknown" }, task.plan?.symbols, tasks.publicMetadataText);
|
|
2165
2221
|
},
|
|
2222
|
+
specsConfig: () => config.specs,
|
|
2223
|
+
specNotice: task => {
|
|
2224
|
+
const snapshot = specRootSnapshot(task);
|
|
2225
|
+
return specInspectorAt(snapshot.root).notice(task.refs.paths ?? []);
|
|
2226
|
+
},
|
|
2227
|
+
inspectSpecs: async (task, head, changedFiles, acks) => {
|
|
2228
|
+
const snapshot = specRootSnapshot(task), inspector = specInspectorAt(snapshot.root);
|
|
2229
|
+
const report = await Promise.resolve(inspector.inspect(head, changedFiles, { complete: changedFiles !== undefined, acks }));
|
|
2230
|
+
const current = specRootSnapshot(task), currentTask = board.get(task.id);
|
|
2231
|
+
if (!sameSpecRoot(snapshot, current) || !currentTask || currentTask.owner !== task.owner) throw new PreEffectToolRefusal("task repository scope changed during spec inspection; retry hub_task_done");
|
|
2232
|
+
if (config.specs.enabled !== true || config.specs.review === "off") return undefined;
|
|
2233
|
+
return report;
|
|
2234
|
+
},
|
|
2235
|
+
specsRead: (action, paths) => projectSpecs.read(action, paths),
|
|
2166
2236
|
sdlc: { config: () => lifecycleConfig, gh: (args, timeout, _cwd, bound) => sdlcGh(args, timeout, opts.cwd, bound) },
|
|
2167
2237
|
board,
|
|
2168
2238
|
bus,
|
|
@@ -2183,13 +2253,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2183
2253
|
read: task => publicCorpus([task.title, ...task.refs.paths ?? [], ...task.plan?.paths ?? [], ...task.plan?.symbols ?? []].join(" "), () => corpusBriefAllowed(task)).map(item => ({ id: item.id, kind: "note", line: `${item.id}: ${item.text.replace(/\s+/g, " ")}` })),
|
|
2184
2254
|
}),
|
|
2185
2255
|
...(memoryStore ? { planFails: (task: import("./board.ts").Task) => {
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2256
|
+
const read = () => {
|
|
2257
|
+
checkMemory();
|
|
2258
|
+
const context = { cwd: opts.cwd, project: memoryProject, repo: memoryRepo, deny: config.local.deny };
|
|
2259
|
+
const nodes = planGuardNodes(task, context);
|
|
2260
|
+
const related = screenMemory(memoryStore.related(memoryProject, nodes, Number.MAX_SAFE_INTEGER));
|
|
2261
|
+
const all = screenMemory(memoryStore.list({ project: memoryProject, kind: "fail", limit: Number.MAX_SAFE_INTEGER }));
|
|
2262
|
+
const rows = [...new Map([...related, ...all].map(row => [row.id, row])).values()];
|
|
2263
|
+
return matchingPlanFails(task, rows, context).slice(0, config.memory.brief_items).map(v => ({ id: `mem:${v.id}`, line: memoryLine(v), kind: v.kind, about: v.about }));
|
|
2264
|
+
};
|
|
2265
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("memory", controlWritePrivacyGuard(true, true, undefined, task.id), read) : read();
|
|
2193
2266
|
} } : {}),
|
|
2194
2267
|
...(memoryStore ? { store: { store: memoryStore, project: memoryProject, repo: memoryRepo, deny: config.local.deny, onNote: (id: string) => void suggestEdges(id) } } : {}),
|
|
2195
2268
|
notify,
|
|
@@ -2212,14 +2285,29 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2212
2285
|
log(`note for ${peer} not kept: ${(e as Error).message}`);
|
|
2213
2286
|
}
|
|
2214
2287
|
},
|
|
2215
|
-
triage: {
|
|
2288
|
+
triage: {
|
|
2289
|
+
classify: (title, detail) => {
|
|
2290
|
+
const classify = () => inference?.triage(title, detail) ?? Promise.resolve(undefined);
|
|
2291
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), classify,
|
|
2292
|
+
() => boundedSafe(`${title}\n${detail}`)) : classify();
|
|
2293
|
+
},
|
|
2294
|
+
onCampus: () => controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), () => onCampus()) : onCampus(),
|
|
2295
|
+
},
|
|
2216
2296
|
// #334: the hub model's split, validated against the same `hub_backlog_split` parts schema a planner's call is, and the source registry for `--source`.
|
|
2217
|
-
intake: { split: (system, user, opts) =>
|
|
2297
|
+
intake: { split: (system, user, opts) => {
|
|
2298
|
+
const split = () => inference?.split(system, user, opts) ?? Promise.resolve(undefined);
|
|
2299
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), split,
|
|
2300
|
+
() => boundedSafe(`${system}\n${user}`)) : split();
|
|
2301
|
+
}, sourceKnown: id => sourceRegistry.get(id) !== undefined,
|
|
2218
2302
|
partsSchema: () => profileTaskTools(resolvedProfile, lifecycleConfig.stages, lifecycleConfig.mode !== "off").find(tool => tool.name === "hub_backlog_split")?.inputSchema.properties.parts },
|
|
2219
2303
|
studySource: id => sourceRegistry.get(id),
|
|
2220
2304
|
experimentCheck: run => checkRunner.result(run),
|
|
2221
2305
|
// The PII screen (issue #198): the model on this machine when MLX/Ollama is enabled, else the gateway on campus only.
|
|
2222
|
-
piiScreen:
|
|
2306
|
+
piiScreen: (text) => {
|
|
2307
|
+
const screen = () => (opts.piiScreen ?? ((value: string) => screenPii(value, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })))(text);
|
|
2308
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("external", controlWritePrivacyGuard(true, true), screen,
|
|
2309
|
+
() => boundedSafe(text)) : screen();
|
|
2310
|
+
},
|
|
2223
2311
|
recordScreen: (record) => event({ type: "pii_screen", ...record }),
|
|
2224
2312
|
quota: (): ReturnType<Budget["headroom"]> => projectBudgetHeadroom(budget.headroom(), accountMembers), // budget is built below; this runs at assignment time
|
|
2225
2313
|
review: config.review,
|
|
@@ -2514,7 +2602,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2514
2602
|
return typeof session === "string" ? session : undefined;
|
|
2515
2603
|
};
|
|
2516
2604
|
const activityRings: Record<string, ActivityRing> = Object.fromEntries(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.capabilities?.watch).map(peer => [peer, new ActivityRing()]));
|
|
2517
|
-
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.
|
|
2605
|
+
const contextPii = (peer: PeerId) => !!turns.get(peer)?.private || board.peerTaskSignals(peer).some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
2606
|
+
// Hoisted for the Tasks ports above; only the returned guard reads fully initialized daemon state.
|
|
2607
|
+
function controlWritePrivacyGuard(admittedAtReceive: boolean, inputPublicAtReceive: boolean, peer?: PeerId, taskId?: number) {
|
|
2608
|
+
return () => {
|
|
2609
|
+
try {
|
|
2610
|
+
if (!admittedAtReceive || !inputPublicAtReceive) return false;
|
|
2611
|
+
if (peer && (turns.get(peer)?.private || contextPii(peer))) return false;
|
|
2612
|
+
if (taskId !== undefined) {
|
|
2613
|
+
const task = board.get(taskId);
|
|
2614
|
+
if (!task || tasks.isPii(task) || !tasks.publicMetadataText(String(task.id))) return false;
|
|
2615
|
+
}
|
|
2616
|
+
return true;
|
|
2617
|
+
} catch { return false; }
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2518
2620
|
// #878: what each peer is doing. Daemon memory only: never persisted (status.json leaves it out), logged, captured or relayed.
|
|
2519
2621
|
const doings = new Map<PeerId, DoingTracker>();
|
|
2520
2622
|
const observeDoing = (peer: PeerId, kind: DoingKind, text?: unknown, learnedPrivate = false) => {
|
|
@@ -2757,22 +2859,25 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
2757
2859
|
};
|
|
2758
2860
|
const snap = (what: string, capture = config.snapshots.enabled, peer?: string): { tree?: string; head?: string | null; commit?: string | null; reflog?: HeadMoveCursor; ms: number; budget?: SnapshotBudget; repo?: Repo } => {
|
|
2759
2861
|
if (!capture) return { ms: 0 };
|
|
2760
|
-
const
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2862
|
+
const captureSnapshot = () => {
|
|
2863
|
+
const t0 = performance.now(), budget = reserveSnapshotBudget();
|
|
2864
|
+
let tree: string | undefined, head: string | null | undefined, commit: string | null | undefined, repo: Repo | undefined, reflog: HeadMoveCursor | undefined;
|
|
2865
|
+
try {
|
|
2866
|
+
repo = peer ? repoFor(peer, budget) : factsRepo;
|
|
2867
|
+
const before = repo && headState(repo, budget);
|
|
2868
|
+
tree = repo && (opts.snapshot ?? snapshot)(repo, budget);
|
|
2869
|
+
const after = repo && headState(repo, budget);
|
|
2870
|
+
head = after?.tree; commit = after?.head;
|
|
2871
|
+
if (!before || !after || before.tree !== after.tree) tree = undefined;
|
|
2872
|
+
if (tree && repo && head !== undefined && after) reflog = headMoveCursor(repo, head, budget, after.head);
|
|
2873
|
+
if (repo && !tree) log(`snapshot failed at ${what}`);
|
|
2874
|
+
} catch (error) {
|
|
2875
|
+
tree = undefined; head = undefined; commit = undefined; reflog = undefined;
|
|
2876
|
+
snapshotFailure(what, error);
|
|
2877
|
+
}
|
|
2878
|
+
return { tree, head, commit, reflog, budget, repo, ms: Math.round(performance.now() - t0) };
|
|
2879
|
+
};
|
|
2880
|
+
return controlWriteTelemetry ? controlWriteTelemetry.observe("snapshot", controlWritePrivacyGuard(true, true, peer as PeerId | undefined), captureSnapshot) : captureSnapshot();
|
|
2776
2881
|
};
|
|
2777
2882
|
function privateSnapshotWindow(): boolean {
|
|
2778
2883
|
return [...turns.values()].some(turn => turn.private) || board.list().some(task => !isClosedTaskState(task.state) && tasks.isPii(task));
|
|
@@ -3244,6 +3349,77 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3244
3349
|
// commit waits for those in flight, or its integrity digest misses their later writes. Completion checks outlive
|
|
3245
3350
|
// their op, so recoveryReady() also waits for `tasks.checksPending()`: a check the commit's stop kills would write.
|
|
3246
3351
|
let taskOpsInFlight = 0;
|
|
3352
|
+
controlWriteTelemetry = new ControlWriteTelemetry({ emit: line => {
|
|
3353
|
+
try { if (tasks.publicMetadataText(line) && boundedSafe(line)) log(line); } catch { /* diagnostics never fail a write */ }
|
|
3354
|
+
} });
|
|
3355
|
+
startupCleanup.push(() => controlWriteTelemetry?.stop());
|
|
3356
|
+
const toolWriteRegistry = new ToolWriteRegistry(instanceId, opts.toolWriteReplayMaxEntries === undefined ? {} : { maxEntries: opts.toolWriteReplayMaxEntries });
|
|
3357
|
+
const toolWriteScope = (c: Client, operation: string): string => {
|
|
3358
|
+
const actor = c.peer ?? USER, peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
3359
|
+
const binding = c.toolsOwner ?? c.externalOwner;
|
|
3360
|
+
return JSON.stringify({ actor, role: c.role ?? "", operation, session: c.session === true, launchId: c.launchId ?? null,
|
|
3361
|
+
generation: binding?.generation ?? (c.peer ? connectionGeneration(c.peer) ?? null : null), owner: binding?.owner.id ?? peer?.id ?? null,
|
|
3362
|
+
executionRoot: c.peer ? executionRoots.get(c.peer) ?? null : null });
|
|
3363
|
+
};
|
|
3364
|
+
const taskWriteId = (value: unknown): number | undefined => {
|
|
3365
|
+
const id = typeof value === "number" ? value : typeof value === "string" && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
|
|
3366
|
+
return Number.isInteger(id) ? id : undefined;
|
|
3367
|
+
};
|
|
3368
|
+
const toolWriteCurrent = (c: Client, sock: Sock, operation: string, payload: Record<string, unknown>, scope: string, result?: Record<string, unknown>): boolean => {
|
|
3369
|
+
try {
|
|
3370
|
+
const actor = c.peer ?? USER;
|
|
3371
|
+
if (!c.authed || c.closed || sock.readyState !== WebSocket.OPEN || !c.role || stopping || recoveryActive() && recoveryPhase !== "preparing"
|
|
3372
|
+
|| c.peer && !membershipAllowed(c.peer) || c.externalOwner && !currentExternalTools(c, true) || c.toolsOwner && !currentImplementerTools(c)) return false;
|
|
3373
|
+
if (c.session && sessionGate(actor, payload)) return false;
|
|
3374
|
+
if (toolWriteScope(c, operation) !== scope || readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
|
|
3375
|
+
|| c.toolsOwner && !IMPLEMENTER_HUB_TOOL_NAMES.includes(operation)) return false;
|
|
3376
|
+
const privateContext = actor !== USER && (!!turns.get(actor)?.private || contextPii(actor));
|
|
3377
|
+
const args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
|
|
3378
|
+
const requestedOwner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
|
|
3379
|
+
if (operation === "hub_task_propose" && (!may(actor, "propose") || requestedOwner && PEER_ID.test(requestedOwner)
|
|
3380
|
+
&& ![actor, USER, HUB, "digest"].includes(requestedOwner) && !may(actor, "assign"))) return false;
|
|
3381
|
+
const safePiiProposalReceipt = operation === "hub_task_propose" && actor !== USER && result?.ok === true && typeof result.text === "string"
|
|
3382
|
+
&& /^task #[1-9]\d*: (?:backlog|proposed|in_progress|in_review|approved|changes_requested|cancelled), owner (?:none|[a-z][a-z0-9-]{0,31}), reviewer (?:none|[a-z][a-z0-9-]{0,31})$/.test(result.text);
|
|
3383
|
+
const taskId = operation === "hub_review" ? taskWriteId(args.id)
|
|
3384
|
+
: result?.ok === true && typeof result.text === "string" ? Number(/task #(\d+):/.exec(result.text)?.[1]) : undefined;
|
|
3385
|
+
if (taskId !== undefined) {
|
|
3386
|
+
const task = Number.isSafeInteger(taskId) && taskId > 0 ? board.get(taskId) : undefined;
|
|
3387
|
+
if (!task) {
|
|
3388
|
+
// Let the original Tasks.review validation return its specific pre-effect refusal for a new/bad target.
|
|
3389
|
+
// A successful cached verdict without its task row is no longer safe to replay.
|
|
3390
|
+
if (operation !== "hub_review" || result?.ok === true) return false;
|
|
3391
|
+
} else {
|
|
3392
|
+
const piiTask = tasks.isPii(task);
|
|
3393
|
+
// ponytail: non-console review/PII replays remain held; narrower privacy needs host-proven per-operation private-turn authority.
|
|
3394
|
+
if (operation === "hub_review" && actor !== USER && privateContext) return false;
|
|
3395
|
+
if (actor !== USER && piiTask && !safePiiProposalReceipt
|
|
3396
|
+
|| operation === "hub_review" && actor !== USER && task.reviewer !== actor
|
|
3397
|
+
|| actor !== USER && result?.ok === true && typeof result.text === "string" && !tasks.publicMetadataText(result.text) && !safePiiProposalReceipt) return false;
|
|
3398
|
+
}
|
|
3399
|
+
}
|
|
3400
|
+
if (actor !== USER && operation === "hub_task_propose" && result?.ok === true && typeof result.text === "string") {
|
|
3401
|
+
const overlapLines = result.text.split(/\r?\n/).filter(line => line.startsWith("Overlaps "));
|
|
3402
|
+
if (overlapLines.length > 1) return false;
|
|
3403
|
+
if (overlapLines.length === 1) {
|
|
3404
|
+
const line = overlapLines[0]!;
|
|
3405
|
+
const markers = [...line.matchAll(/#([1-9]\d*) \(owner ([^)\r\n]{1,128})\)/g)];
|
|
3406
|
+
const markerPrefixes = [...line.matchAll(/#([^()\r\n]{1,128}) \(owner /g)];
|
|
3407
|
+
if (!markers.length || markers.length > 64 || markerPrefixes.length !== markers.length) return false;
|
|
3408
|
+
const refs = new Set<number>();
|
|
3409
|
+
for (const marker of markers) {
|
|
3410
|
+
const id = Number(marker[1]);
|
|
3411
|
+
if (!Number.isSafeInteger(id) || id < 1) return false;
|
|
3412
|
+
refs.add(id);
|
|
3413
|
+
}
|
|
3414
|
+
if (refs.size !== markers.length) return false;
|
|
3415
|
+
const facts = board.taskPrivacySignals([...refs]);
|
|
3416
|
+
if (facts.length !== refs.size || facts.some(task => tasks.isPii(task))) return false;
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
if (actor !== USER && typeof result?.error === "string" && result.outcome !== "unknown" && !tasks.publicMetadataText(result.error)) return false;
|
|
3420
|
+
return true;
|
|
3421
|
+
} catch { return false; }
|
|
3422
|
+
};
|
|
3247
3423
|
/**
|
|
3248
3424
|
* #604 ORIGIN side. Order matters: the caller's authority and the PII gates run first (nothing has left this project), then the target is resolved from the machine registry and its own
|
|
3249
3425
|
* status descriptor is read, and only then is the request sent. The target answers with the task id it created, which is recorded here. A refusal at any step leaves no row in either board.
|
|
@@ -3354,6 +3530,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3354
3530
|
try { return renderHeldHint(bus.heldDeliveryHints(peer), Date.now(), deliveryRetryAllowed(bus.peers.get(peer))); }
|
|
3355
3531
|
catch { return ""; } // unavailable telemetry is not a zero count or a reason to fail the tool itself
|
|
3356
3532
|
}
|
|
3533
|
+
function inboxHeldMetadata(peer: PeerId) {
|
|
3534
|
+
try { return bus.heldDeliveryHints(peer); }
|
|
3535
|
+
catch { return undefined; } // the inbox pull is already settled; advisory held-row metadata cannot hide its result
|
|
3536
|
+
}
|
|
3357
3537
|
/** A model-written peer argument: a peer id, or nothing for absent, null or "". Anything else is refused. */
|
|
3358
3538
|
const peerArg = (v: unknown, name: string): PeerId | undefined => {
|
|
3359
3539
|
if (v == null || v === "") return undefined;
|
|
@@ -3504,14 +3684,19 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3504
3684
|
return JSON.stringify({ invocation: row.id, state: "queued", note: "the result arrives later as one message from the hub, addressed to you only; do not wait for it in this turn" });
|
|
3505
3685
|
}
|
|
3506
3686
|
async function taskOpBody(by: PeerId, op: string, a: Record<string, any>, inProcess = false, piiTurn = false): Promise<string> {
|
|
3687
|
+
if (inProcess && (op === "hub_task_propose" || op === "hub_review") && a && typeof a === "object" && Object.hasOwn(a, "request_id")) throw new PreEffectToolRefusal("request_id replay keys require the control-protocol task transport");
|
|
3507
3688
|
if (BOUNDED_TASK_TOOLS.has(op)) {
|
|
3508
3689
|
const keys = op === "hub_task_spec" ? ["id"] : op === "hub_check_start" ? ["id", "check", "files"] : ["run"];
|
|
3509
3690
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !keys.includes(key))
|
|
3510
3691
|
|| (keys.includes("id") ? !Number.isSafeInteger(a.id) || a.id < 1 : typeof a.run !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(a.run))) throw new PreEffectToolRefusal("invalid bounded task request");
|
|
3511
3692
|
if (by !== USER && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("bounded task tools unavailable during a PII turn");
|
|
3512
3693
|
}
|
|
3694
|
+
if (op === "hub_specs" && (piiTurn || turns.get(by)?.private || contextPii(by))) throw new PreEffectToolRefusal("spec inspection unavailable during a private turn");
|
|
3513
3695
|
if (MEMORY_READ_TOOLS.has(op)) validateMemoryRead(op, a);
|
|
3514
3696
|
if (op === "hub_memory_correct") correctionInput(a);
|
|
3697
|
+
if (op === "hub_task_propose" && by !== USER && (piiTurn || turns.get(by)?.private)) {
|
|
3698
|
+
throw new PreEffectToolRefusal("hub_task_propose is not available while working on a PII task: its text must not leave this machine");
|
|
3699
|
+
}
|
|
3515
3700
|
if (CONDUCTOR_TOOL_NAMES.has(op)) {
|
|
3516
3701
|
if (piiTurn && ["hub_task_assign", "hub_task_escalate", "hub_peer_start"].includes(op)) throw new PreEffectToolRefusal("conductor mutations are unavailable during a PII turn");
|
|
3517
3702
|
const result = await conductor.execute(by, op, a);
|
|
@@ -3682,6 +3867,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3682
3867
|
return JSON.stringify(publicCheck(receipt));
|
|
3683
3868
|
}
|
|
3684
3869
|
switch (op) {
|
|
3870
|
+
case "hub_specs":
|
|
3871
|
+
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "action" && key !== "paths")) throw new PreEffectToolRefusal("hub_specs accepts only action and paths");
|
|
3872
|
+
return JSON.stringify(tasks.specsRead(by, a.action, a.paths));
|
|
3685
3873
|
case "hub_delivery_retry": {
|
|
3686
3874
|
if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => key !== "delivery_id") || typeof a.delivery_id !== "string" || a.delivery_id.length < 1 || a.delivery_id.length > 256) throw new PreEffectToolRefusal("hub_delivery_retry accepts only your delivery_id; completed and discard require the person");
|
|
3687
3875
|
if (!deliveryRetryAllowed(bus.peers.get(by))) throw new PreEffectToolRefusal("delivery-only peers require the person to resolve held deliveries");
|
|
@@ -3689,6 +3877,16 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3689
3877
|
bus.retryDelivery(by, a.delivery_id);
|
|
3690
3878
|
return `retry queued for delivery ${a.delivery_id}; check for earlier effects before acting`;
|
|
3691
3879
|
}
|
|
3880
|
+
case "hub_delivery_resolve": {
|
|
3881
|
+
if (by === USER || by === HUB || !a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["delivery_id", "revision", "action", "reason"].includes(key))
|
|
3882
|
+
|| typeof a.delivery_id !== "string" || !a.delivery_id.trim() || a.delivery_id.length > 256
|
|
3883
|
+
|| !Number.isSafeInteger(a.revision) || a.revision < 0 || !["completed", "discard"].includes(a.action)
|
|
3884
|
+
|| typeof a.reason !== "string" || !a.reason.trim() || a.reason.trim().length > 1000 || a.reason.length > 1000
|
|
3885
|
+
|| !tasks.publicMetadataText(a.reason.trim()) || !boundedSafe(a.reason.trim())) throw new PreEffectToolRefusal("hub_delivery_resolve needs only delivery_id, revision (the whole number hub_inbox showed), action completed or discard, and a public reason of 1 to 1000 characters; call hub_inbox for the revision and keep private or sensitive text out of the reason");
|
|
3886
|
+
if (stopping || recoveryActive()) throw new PreEffectToolRefusal("the hub is not accepting delivery changes now (recovery or shutdown); run ahub status, then call hub_delivery_resolve again after it recovers");
|
|
3887
|
+
bus.resolveOwnDelivery(by, a.delivery_id, a.revision, a.action, a.reason.trim());
|
|
3888
|
+
return `your held delivery was marked ${a.action}`;
|
|
3889
|
+
}
|
|
3692
3890
|
case "hub_backlog_list":
|
|
3693
3891
|
return JSON.stringify(tasks.backlogList(by));
|
|
3694
3892
|
case "hub_release_new":
|
|
@@ -3764,19 +3962,21 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3764
3962
|
const t = await tasks.propose(by, a);
|
|
3765
3963
|
const repoHint = tasks.refsRepoHint(t); // #723: advice only; never part of the write
|
|
3766
3964
|
const submodules = tasks.submoduleNotice(t); // #798 D1: advice only too
|
|
3767
|
-
|
|
3965
|
+
const specs = tasks.specNotice(t, by);
|
|
3966
|
+
if (t.state === "backlog") return [line(t), specs, repoHint, submodules].filter(Boolean).join("\n");
|
|
3768
3967
|
const overlap = tasks.overlaps(t, t.owner === by);
|
|
3769
3968
|
if (t.owner === by && tasks.silentFor(t.id) && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // as for an accept
|
|
3770
|
-
return [line(t), tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
|
|
3969
|
+
return [line(t), specs, tasks.namedOwnerNote(t, a.owner as PeerId | undefined, by), overlap, tasks.claimRoleGuidance(t, by), t.owner === by ? tasks.planGuardText(t) : "", repoHint, submodules].filter(Boolean).join("\n");
|
|
3771
3970
|
}
|
|
3772
3971
|
case "hub_task_accept": {
|
|
3773
3972
|
// S1 spec acceptance and S9 plan review are checked against one task snapshot before acceptance.
|
|
3774
3973
|
const t = await tasks.acceptReviewed(by, a.id, a.plan, a.refs);
|
|
3974
|
+
const specs = tasks.specNotice(t, by);
|
|
3775
3975
|
// A silent cohort (issue #107): the owners' plans come with every accept, as what the newcomer works from.
|
|
3776
3976
|
const silent = tasks.silentFor(t.id);
|
|
3777
3977
|
const overlap = a.plan == null && a.refs == null && !silent ? "" : tasks.overlaps(t);
|
|
3778
3978
|
if (silent && factsOn()) factsFor(by).sawPlans(by, tasks.overlapTasks(t)); // the plans in this answer need no fact later
|
|
3779
|
-
return [line(t), overlap, tasks.planGuardText(t), tasks.submoduleNotice(t)].filter(Boolean).join("\n"); // #798 D1
|
|
3979
|
+
return [line(t), specs, overlap, tasks.planGuardText(t), tasks.submoduleNotice(t), tasks.worktreeNote(t, by)].filter(Boolean).join("\n"); // #798 D1
|
|
3780
3980
|
}
|
|
3781
3981
|
case "hub_task_cancel": {
|
|
3782
3982
|
if (!a || typeof a !== "object" || Array.isArray(a) || Object.keys(a).some(key => !["id", "reason", "superseded_by", "revision"].includes(key))
|
|
@@ -3789,7 +3989,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3789
3989
|
case "hub_task_decline":
|
|
3790
3990
|
return line(await tasks.decline(by, a.id, a.reason, a.capacity === true));
|
|
3791
3991
|
case "hub_task_done": {
|
|
3792
|
-
|
|
3992
|
+
// #1115: a hub-sandboxed peer's worktree is staged, committed and pushed by the hub first, so the done carries the real head and branch.
|
|
3993
|
+
const handed = await tasks.worktreeHandoff(by, a.id, a.refs);
|
|
3994
|
+
const t = await tasks.done(by, a.id, a.summary, handed ?? a.refs, a.deliverables, a.spec_ack);
|
|
3793
3995
|
const completion = t.history.findLast(h => h.by === by && (h.event === "done" || h.event === "done (checking)"));
|
|
3794
3996
|
if (!piiTurn && !tasks.isPii(t) && completion && !completion.withheld && typeof a.summary === "string" && completion.note?.startsWith(a.summary)) {
|
|
3795
3997
|
citeMemory(by, a.summary, `summary:${t.id}:${t.history.indexOf(completion)}`, [newEnvelope(by, "", { refs: { task: String(t.id) } })]);
|
|
@@ -3798,8 +4000,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3798
4000
|
const last = t.history.at(-1);
|
|
3799
4001
|
if (last?.event === "integration requested" && last.by === HUB) return last.note ?? "";
|
|
3800
4002
|
const extra = [tasks.takeDoneNote(t.id), tasks.checkoutNote()].filter(Boolean).join("\n"); // held silence notices, then the #572 checkout advisory
|
|
3801
|
-
const
|
|
3802
|
-
|
|
4003
|
+
const doneLine = by === USER ? tasks.resultLine(t, by) : line(t);
|
|
4004
|
+
const result = tasks.isChecking(t.id) ? `${doneLine}; its check is queued or running, and the result comes as a task message` : doneLine;
|
|
4005
|
+
const specReport = by === USER ? "" : tasks.specReportText(t, by);
|
|
4006
|
+
return [result, specReport, extra].filter(Boolean).join("\n");
|
|
3803
4007
|
}
|
|
3804
4008
|
case "hub_sdlc_record":
|
|
3805
4009
|
await tasks.specStageGate(by, a.id, a.receipt); // #481 S1: a live spec edit-time check before a build stage
|
|
@@ -3839,7 +4043,13 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
3839
4043
|
if (!release) throw new PreEffectToolRefusal("no such release");
|
|
3840
4044
|
releaseId = release.id;
|
|
3841
4045
|
}
|
|
3842
|
-
|
|
4046
|
+
// #1083: an agent always pages (default 50 compact rows) because the whole board is over every tool-output limit; the person's console keeps the full array unless it names a paging argument.
|
|
4047
|
+
const listQuery = parseListQuery(a, by !== USER);
|
|
4048
|
+
const shown = board.list(a.ready === true ? "proposed" : a.state).filter((t) => (by === USER || t.state !== "backlog") && (releaseId === undefined || t.release === releaseId) && (a.ready !== true || !tasks.waitsFor(t).length));
|
|
4049
|
+
const listRow = (t: Task) => { const publicTask = tasks.publicView(t); const view: Record<string, unknown> = onPrem ? { ...t, ...holderReasons(publicTask) } : publicTask; const { history: _h, ...rest } = view; return { ...rest, flowRevision: t.history.length, ...(a.columnMetadata === true ? { started: taskHasStarted(t) } : {}) }; };
|
|
4050
|
+
if (!listQuery) return JSON.stringify(shown.map(listRow));
|
|
4051
|
+
const page = pageTasks(shown, listQuery);
|
|
4052
|
+
return JSON.stringify({ tasks: page.rows.map(t => listQuery.detail ? listRow(t) : compactRow(listRow(t))), total: page.total, ...(page.next ? { next: page.next } : {}) });
|
|
3843
4053
|
}
|
|
3844
4054
|
}
|
|
3845
4055
|
if (op === "task_show" && by !== USER) {
|
|
@@ -4533,6 +4743,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4533
4743
|
}
|
|
4534
4744
|
return checkoutInTick.branch;
|
|
4535
4745
|
};
|
|
4746
|
+
/**
|
|
4747
|
+
* The crash-recovery lines that still stand (#1082), read when status is asked: a line about a peer goes once that peer is attached, and the headline's count is the crash's own deliveries that are
|
|
4748
|
+
* still in needs_review, so it matches `ahub queue list` and reaches zero when the operator has resolved them.
|
|
4749
|
+
*/
|
|
4750
|
+
const crashView = (): string[] => {
|
|
4751
|
+
if (!crashNotices.length) return [];
|
|
4752
|
+
const attached = new Set<string>();
|
|
4753
|
+
for (const notice of crashNotices) if (notice.peer !== undefined && bus.peers.has(notice.peer) && bus.peers.get(notice.peer)!.state !== "offline") attached.add(notice.peer);
|
|
4754
|
+
let inFlight = 0;
|
|
4755
|
+
if (crashLostIds.size) { try { for (const row of bus.queueList(undefined, true)) if (row.state === "needs_review" && crashLostIds.has(row.id)) inFlight++; } catch { inFlight = crashLostIds.size; } }
|
|
4756
|
+
return openCrashNotices(crashNotices, { attached, inFlight });
|
|
4757
|
+
};
|
|
4536
4758
|
const status = (includeAttentionItems = false, includeAttentionInput = false, includeDoing = false) => {
|
|
4537
4759
|
const taskNeeds = board.statusTasks();
|
|
4538
4760
|
const ownerIsolation = ownerIsolationProjection();
|
|
@@ -4540,9 +4762,10 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4540
4762
|
const pendingTaskOffers = tasks.pendingOffers().filter(offer => offer.pullOnly === true);
|
|
4541
4763
|
const peerIds = byKind(statusPeerIds(taskNeeds));
|
|
4542
4764
|
const queueBackpressure = tasks.queuePressureStatus(peerIds);
|
|
4765
|
+
const crashLines = crashView();
|
|
4543
4766
|
const view = {
|
|
4544
4767
|
...(queueBackpressure ? { queueBackpressure } : {}),
|
|
4545
|
-
...(
|
|
4768
|
+
...(crashLines.length ? { crash: crashLines } : {}),
|
|
4546
4769
|
projectId,
|
|
4547
4770
|
instanceId,
|
|
4548
4771
|
rooms: roomCommands.statusView(),
|
|
@@ -4599,6 +4822,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4599
4822
|
isolatedChecks: { pending: checkPending.size, orphans: orphanChecks, unknownReceipts: checkRunner.unknownReceipts ?? [], maintenanceUnavailable: checkRunner.maintenanceUnavailable === true },
|
|
4600
4823
|
taskCapabilities: peerCapabilities(),
|
|
4601
4824
|
tasks: board.counts(),
|
|
4825
|
+
...(includeAttentionItems && controlWriteTelemetry ? { controlWriteTiming: controlWriteTelemetry.status() } : {}),
|
|
4602
4826
|
taskStalls, pendingTaskOffers,
|
|
4603
4827
|
...(bus.storageError ? { deliveryError: bus.storageError } : {}),
|
|
4604
4828
|
...(recoveryOperationId ? { recovery: { operationId: recoveryOperationId, phase: recoveryPhase, ready: recoveryReady() } } : {}),
|
|
@@ -4629,20 +4853,28 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4629
4853
|
// (the status reply is unchanged); readers without it judge the pid alone, as 0.12.20 did.
|
|
4630
4854
|
const pidSignature = processSignature(process.pid);
|
|
4631
4855
|
const writeStatus = () => {
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4856
|
+
const write = () => {
|
|
4857
|
+
try {
|
|
4858
|
+
const file = join(opts.stateDir, "status.json"); // clients parse this on every connect: replace it atomically
|
|
4859
|
+
writeFileSync(`${file}.${instanceId}.tmp`, `${JSON.stringify({ ...status(), ...(pidSignature ? { pidSignature } : {}) }, null, 2)}\n`);
|
|
4860
|
+
renameSync(`${file}.${instanceId}.tmp`, file);
|
|
4861
|
+
} catch { /* the state dir is gone; the watchdog is stopping the hub (issue #56) */ }
|
|
4862
|
+
};
|
|
4863
|
+
if (controlWriteTelemetry) controlWriteTelemetry.observe("status-hook", () => true, write);
|
|
4864
|
+
else write();
|
|
4637
4865
|
};
|
|
4638
4866
|
const taskChanged = board.onChange;
|
|
4639
4867
|
board.onChange = (task, entry) => {
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4868
|
+
const changed = () => {
|
|
4869
|
+
try { taskChanged?.(task, entry); }
|
|
4870
|
+
finally {
|
|
4871
|
+
try { handoffNotifyOrigin(task, entry); } catch { /* never throw after a board write */ }
|
|
4872
|
+
try { settleSupersededDeliveries(); }
|
|
4873
|
+
finally { writeStatus(); } // committed task transitions refresh token-free attention even without bus activity
|
|
4874
|
+
}
|
|
4875
|
+
};
|
|
4876
|
+
if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", controlWritePrivacyGuard(true, true, undefined, task.id), changed);
|
|
4877
|
+
else changed();
|
|
4646
4878
|
};
|
|
4647
4879
|
|
|
4648
4880
|
// A queue change is not a bus event: without this, status.json reports the queue as it was before the last
|
|
@@ -4677,6 +4909,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4677
4909
|
let reportingQueues = false;
|
|
4678
4910
|
let queuePressureStatusQueued = false;
|
|
4679
4911
|
bus.onQueues = () => {
|
|
4912
|
+
const changed = () => {
|
|
4680
4913
|
if (stopping || settlingSuperseded || reportingQueues) return;
|
|
4681
4914
|
remoteQueueChanged();
|
|
4682
4915
|
reportingQueues = true;
|
|
@@ -4702,6 +4935,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4702
4935
|
});
|
|
4703
4936
|
}
|
|
4704
4937
|
}
|
|
4938
|
+
};
|
|
4939
|
+
if (controlWriteTelemetry) controlWriteTelemetry.observe("queue-hook", () => true, changed);
|
|
4940
|
+
else changed();
|
|
4705
4941
|
};
|
|
4706
4942
|
// Opening the journal/restoring the snapshot preceded callbacks; classify those holds too.
|
|
4707
4943
|
settleSupersededDeliveries();
|
|
@@ -4900,34 +5136,35 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
4900
5136
|
} catch (error) { log(`session record not written: ${(error as Error).message}`); }
|
|
4901
5137
|
};
|
|
4902
5138
|
const recoverAfterCrash = async (prev: SessionsFile) => {
|
|
4903
|
-
|
|
5139
|
+
// `peer` marks a line that asks something of that peer's owner (it clears when the peer attaches); a line with neither is what recovery found or did and stays (#1082).
|
|
5140
|
+
const report = (line: string, notice: { peer?: string; inFlight?: true } = {}, shown = line) => { crashNotices.push({ text: line, ...notice }); crashReported = true; notify(`crash recovery: ${shown}`); };
|
|
4904
5141
|
const lostCount = [...lost.values()].reduce((n, l) => n + l.length, 0);
|
|
4905
|
-
report(`the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
5142
|
+
report("the previous hub run stopped without shutting down", { inFlight: true }, `the previous hub run stopped without shutting down${lostCount ? `; ${lostCount} deliveries it had in flight are in needs_review (ahub queue list)` : ""}`);
|
|
4906
5143
|
const start = (peer: string, args: Parameters<typeof startPeer>[1]) => startPeer(peer, args).catch((e: Error) => ({ ok: false, error: e.message }));
|
|
4907
5144
|
for (const step of crashPlan(prev.peers, declarations, adapterCatalog)) {
|
|
4908
|
-
if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start
|
|
4909
|
-
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session"); continue; }
|
|
5145
|
+
if (isExternalInstance(step.peer)) { report(`${step.peer}: external module instance stays stopped; use an explicit same-binding start`, { peer: step.peer }); continue; }
|
|
5146
|
+
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && archivedPiContinuity) { report("pi: archived continuity evidence holds crash-session resume and fresh fallback; pi.auto_start uses only the recorded archived session", { peer: step.peer }); continue; }
|
|
4910
5147
|
// `pi.auto_start` already asks for Pi (#66): its recorded headless session comes back, auto-resume or not, and a
|
|
4911
5148
|
// fresh one starts when that fails or there is nothing headless to resume. Pi runs on-prem: no cloud quota.
|
|
4912
5149
|
if (!declarations[step.peer] && definitionOf(step.peer)?.session.restoreOnStart && piAutoStart()) {
|
|
4913
5150
|
if (step.resume) {
|
|
4914
5151
|
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
4915
5152
|
if (r.ok) { report(`pi resumed (pi.auto_start): ${step.how}`); continue; }
|
|
4916
|
-
report(`pi not resumed (${String(r.error)}); ${step.how}`);
|
|
5153
|
+
report(`pi not resumed (${String(r.error)}); ${step.how}`); // what happened, true after a fresh start too: history. Whether Pi is back is the last line of this branch.
|
|
4917
5154
|
} else report(step.how);
|
|
4918
5155
|
// #269: a Pi that ran in a terminal is not replaced by a headless one unless headless is its start mode.
|
|
4919
|
-
if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless"); continue; }
|
|
5156
|
+
if (step.tui && startMode(step.peer) === "tui") { report("pi.auto_start starts no headless Pi in place of a terminal one: run the command above, or set peers.pi.start_mode to headless", { peer: step.peer }); continue; }
|
|
4920
5157
|
const fresh = await start(step.peer, { ...(step.fresh as Parameters<typeof startPeer>[1]), fresh: true });
|
|
4921
5158
|
const back = step.tui ? `; to go back to the recorded session, run ahub kill, start the hub without pi.auto_start, then the command above` : "";
|
|
4922
|
-
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})
|
|
5159
|
+
report(fresh.ok ? `pi.auto_start started a fresh session${back}` : `pi.auto_start could not start Pi either (${String(fresh.error)})`, fresh.ok ? {} : { peer: step.peer });
|
|
4923
5160
|
continue;
|
|
4924
5161
|
}
|
|
4925
5162
|
if (!step.resume || !autoResume()) {
|
|
4926
|
-
report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}
|
|
5163
|
+
report(`${step.how}${step.resume ? " (recovery.auto_resume_after_crash is off)" : ""}`, { peer: step.peer });
|
|
4927
5164
|
continue;
|
|
4928
5165
|
}
|
|
4929
5166
|
const r = await start(step.peer, step.resume as Parameters<typeof startPeer>[1]);
|
|
4930
|
-
report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}
|
|
5167
|
+
report(r.ok ? `${step.peer} resumed: ${step.how}` : `${step.peer} not resumed (${String(r.error)}); ${step.how}`, r.ok ? {} : { peer: step.peer });
|
|
4931
5168
|
}
|
|
4932
5169
|
writeStatus();
|
|
4933
5170
|
};
|
|
@@ -5361,7 +5598,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5361
5598
|
if (!isStartablePeer(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, qwen (when on roster), pi (when enabled) and local" };
|
|
5362
5599
|
if (!membershipAllowed(peer)) return { ok: false, error: `${peer} is off roster; a person can add it with ahub roster add ${peer}` };
|
|
5363
5600
|
if (!startable(peer)) return { ok: false, error: "unknown peer; the hub starts claude, codex, kimi, pi (when enabled) and local" };
|
|
5364
|
-
if (recoveryActive()) return { ok: false, error:
|
|
5601
|
+
if (recoveryActive()) return { ok: false, error: startHeldByRecovery(peer) };
|
|
5365
5602
|
const continuity = definitionOf(peer)?.start.autoStart === by
|
|
5366
5603
|
? piContinuity.state === "ready" ? (piContinuity = readRecoveryContinuity(opts.stateDir, { projectId, projectRoot: opts.cwd })) : piContinuity
|
|
5367
5604
|
: undefined;
|
|
@@ -5423,6 +5660,15 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5423
5660
|
/** The CLI command that starts a peer: `ahub <kind> --as <id>` for a declared instance. */
|
|
5424
5661
|
const startCommand = (peer: string): string => { const kind = definitionOf(peer)?.kind; return kind && kind !== peer ? `ahub ${kind} --as ${peer}` : `ahub ${peer}`; };
|
|
5425
5662
|
|
|
5663
|
+
/** The refusal of a start request while recovery holds mutations (#1088): the old sentence first, then whether the peer already runs and the step that works. */
|
|
5664
|
+
const startHeldByRecovery = (peer: unknown): string => {
|
|
5665
|
+
const held = "recovery is holding mutations";
|
|
5666
|
+
if (typeof peer !== "string" || !PEER_ID.test(peer)) return `${held}; no peer was started. Wait for the recovery to finish (ahub recovery status shows where it is), then start the peer again`;
|
|
5667
|
+
const owner = bus.peers.get(peer);
|
|
5668
|
+
if (owner && owner.state !== "offline") return `${held}, and ${peer} is already running, so there is nothing to start. Wait for the recovery to finish (ahub recovery status shows where it is), then check ${peer} with ahub status. If the recovery names ${peer} as blocked, a person repairs it with ahub recovery reattach <operation-id> ${peer}, or ends the hold with ahub recovery resume or abort`;
|
|
5669
|
+
return `${held}, so ${peer} was not started. Wait for the recovery to finish (ahub recovery status shows where it is), then start it with ${startCommand(peer)}`;
|
|
5670
|
+
};
|
|
5671
|
+
|
|
5426
5672
|
async function stopPeer(peer: unknown, rosterRemoval = false, operational?: {actor:string;current:()=>void}): Promise<Record<string, unknown>> {
|
|
5427
5673
|
operational?.current();
|
|
5428
5674
|
if (!rosterRemoval && rosterChanging !== undefined && rosterChanging === peer) return { ok: false, error: `${String(peer)} roster change is in progress` };
|
|
@@ -5657,6 +5903,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5657
5903
|
...(instance?.provider ? { provider: instance.provider } : {}),
|
|
5658
5904
|
...(args.model ?? instance?.model ? { launchModel: (args.model ?? instance?.model)! } : {}), ...(args.sessionId ? { resumeSessionId: args.sessionId } : {}), cwd: executionCwd, watchdogMs: config.watchdog_ms,
|
|
5659
5905
|
toolCeilingMs: adapterTiming.acpToolCeilingMs(), activityBatchMs: adapterTiming.acpActivityBatchMs(), steerTimeoutMs: adapterTiming.jcodeSteerTimeoutMs(),
|
|
5906
|
+
tuiHintContext: () => ({ tui: startMode(peer) === "tui", afterCrash: crashReported }),
|
|
5660
5907
|
preamble: sessionRoleContract(peer), log: () => log("Jcode native diagnostic: details withheld"),
|
|
5661
5908
|
// #801: Jcode 0.99.1's usage_update is `{ used, size }`, the live session's context occupancy: a context reading bound to the adapter's own session id, never a tokens event
|
|
5662
5909
|
// or a quota window. An invalid shape reports a null reading (unknown, not the previous value).
|
|
@@ -6437,6 +6684,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6437
6684
|
return {
|
|
6438
6685
|
id: row.id, peer: row.peer, state: row.state, revision: row.revision,
|
|
6439
6686
|
...roomChat.evidence(row.peer, row.id),
|
|
6687
|
+
...(detail && bus.deliveryResolution(row.id) ? { resolvedBy: bus.deliveryResolution(row.id) } : {}),
|
|
6440
6688
|
...(!membershipAllowed(row.peer) ? { rostered: false, holdReason: "off roster" } : {}),
|
|
6441
6689
|
createdAt: row.createdAt, updatedAt: row.updatedAt,
|
|
6442
6690
|
envelopeIds: originals.map((env) => env.id),
|
|
@@ -7605,6 +7853,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7605
7853
|
? `target lacks task_cancel; cancelled tasks: ${ids.map(id => `#${id}`).join(", ")}` : undefined;
|
|
7606
7854
|
};
|
|
7607
7855
|
const incompatibleRecoveryTarget = () => {
|
|
7856
|
+
if (config.specs.enabled && config.specs.review === "require" && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("design_specs"))) return "target lacks design_specs; specs.enabled is on and specs.review is require; use a supporting release or explicitly change specs.enabled/specs.review";
|
|
7608
7857
|
if (board.hasConductorManualState() && (!Array.isArray(msg.targetFeatures) || !msg.targetFeatures.includes("conductor_recovery"))) return "target lacks conductor_recovery; open conductor manual-assignment intent cannot be preserved";
|
|
7609
7858
|
if (allExternalInstanceIds().length && (!Array.isArray(msg.targetFeatures) || !["external_adapter_instances", "external_adapter_modules", "peer_instances"].every(feature => msg.targetFeatures.includes(feature)))) return "target lacks external_adapter_instances or its module/instance prerequisites";
|
|
7610
7859
|
if (!(msg.op === "inspect" && recoveryPhase === "restored")) for (const id of allExternalInstanceIds()) if (!retainedExternalBinding(id, true)) return "external instance retained binding changed or unavailable";
|
|
@@ -7951,13 +8200,68 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7951
8200
|
if (!result.ok) throw new Error(String(result.error));
|
|
7952
8201
|
}
|
|
7953
8202
|
|
|
7954
|
-
function
|
|
8203
|
+
function onControlMessage(sock: Sock, msg: any): void {
|
|
8204
|
+
const client = sock.data;
|
|
8205
|
+
const admittedAtReceive = client.authed && !!client.role, peer = client.peer;
|
|
8206
|
+
if (!admittedAtReceive) return onMessage(sock, msg);
|
|
8207
|
+
const metadata = controlWriteMetadata(msg);
|
|
8208
|
+
if (!metadata || !controlWriteTelemetry) return onMessage(sock, msg);
|
|
8209
|
+
const inputPublicAtReceive = (() => {
|
|
8210
|
+
const rawArgs = msg?.args;
|
|
8211
|
+
if (rawArgs === undefined) return true;
|
|
8212
|
+
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) return false;
|
|
8213
|
+
try {
|
|
8214
|
+
const idDescriptor = Object.getOwnPropertyDescriptor(rawArgs, "id");
|
|
8215
|
+
if (idDescriptor && (!Object.hasOwn(idDescriptor, "value") || idDescriptor.value !== undefined) && metadata.task === undefined) return false;
|
|
8216
|
+
const stack: { value: unknown; depth: number }[] = [{ value: rawArgs, depth: 0 }];
|
|
8217
|
+
const text: string[] = [];
|
|
8218
|
+
let bytes = 0, nodes = 0;
|
|
8219
|
+
while (stack.length) {
|
|
8220
|
+
const current = stack.pop()!;
|
|
8221
|
+
if (++nodes > 2048 || current.depth > 16) return undefined;
|
|
8222
|
+
if (typeof current.value === "string") {
|
|
8223
|
+
bytes += Buffer.byteLength(current.value);
|
|
8224
|
+
if (bytes > 32 * 1024) return undefined;
|
|
8225
|
+
text.push(current.value);
|
|
8226
|
+
} else if (Array.isArray(current.value)) {
|
|
8227
|
+
if (current.value.length > 256) return undefined;
|
|
8228
|
+
for (const value of current.value) stack.push({ value, depth: current.depth + 1 });
|
|
8229
|
+
} else if (current.value && typeof current.value === "object") {
|
|
8230
|
+
const values = Object.values(current.value);
|
|
8231
|
+
if (values.length > 256) return undefined;
|
|
8232
|
+
for (const value of values) stack.push({ value, depth: current.depth + 1 });
|
|
8233
|
+
}
|
|
8234
|
+
}
|
|
8235
|
+
const joined = text.join("\n");
|
|
8236
|
+
return tasks.publicMetadataText(joined) && boundedSafe(joined);
|
|
8237
|
+
} catch { return undefined; }
|
|
8238
|
+
})();
|
|
8239
|
+
const privacyCurrent = controlWritePrivacyGuard(admittedAtReceive, inputPublicAtReceive === true, peer, metadata.task);
|
|
8240
|
+
let finishControlWrite = () => {};
|
|
8241
|
+
let finished = false;
|
|
8242
|
+
const finish = () => { if (!finished) { finished = true; finishControlWrite(); } };
|
|
8243
|
+
try {
|
|
8244
|
+
void controlWriteTelemetry.run(metadata, privacyCurrent, () => {
|
|
8245
|
+
const completed = new Promise<void>(resolve => { finishControlWrite = resolve; });
|
|
8246
|
+
try {
|
|
8247
|
+
controlWriteSpan("daemon-dispatch", () => onMessage(sock, msg, finish));
|
|
8248
|
+
} catch (error) { finish(); throw error; }
|
|
8249
|
+
return completed;
|
|
8250
|
+
});
|
|
8251
|
+
} catch (error) { finish(); throw error; }
|
|
8252
|
+
}
|
|
8253
|
+
|
|
8254
|
+
function onMessage(sock: Sock, msg: any, finishControlWrite: () => void = () => {}): void {
|
|
7955
8255
|
const c = sock.data;
|
|
7956
8256
|
let refusalRecorded=false;
|
|
7957
8257
|
const reply = (body: Record<string, unknown>) => {
|
|
8258
|
+
const sendReply = () => {
|
|
8259
|
+
const writeRequestId = typeof msg.requestId === "string" && msg.requestId.length <= 256 && !/[\p{Cc}\p{Cf}]/u.test(msg.requestId)
|
|
8260
|
+
&& toolWriteOperation(msg) !== undefined && body.requestId === msg.requestId ? msg.requestId : undefined;
|
|
8261
|
+
let writeRefusal: string | undefined;
|
|
7958
8262
|
const label=operationalRequestLabel(msg);
|
|
7959
|
-
if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c))
|
|
7960
|
-
if (c.toolsOwner && !currentImplementerTools(c))
|
|
8263
|
+
if (msg.t !== "hello" && c.externalOwner && !currentExternalTools(c)) { writeRefusal = "external instance launch or execution root changed"; body = { ok: false, error: writeRefusal }; }
|
|
8264
|
+
if (c.toolsOwner && !currentImplementerTools(c)) { writeRefusal = "Claude child generation or execution root changed"; body = { ok: false, error: writeRefusal, ...(label ? {authorityRefused:true,authorityScope:"operational"}: {}) }; }
|
|
7961
8265
|
if(c.authed && c.role==="tools" && c.peer && body.ok===false && label) {
|
|
7962
8266
|
if(body.authorityRefused===true) {
|
|
7963
8267
|
if(!refusalRecorded)refusalRecorded=event({type:"agent_cli",peer:c.peer,command:label,refused:true});
|
|
@@ -7965,7 +8269,48 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
7965
8269
|
body={...body,error,refusalAudited:refusalRecorded};
|
|
7966
8270
|
} else body={...body,authorityRefused:false};
|
|
7967
8271
|
}
|
|
8272
|
+
if (writeRequestId && body.requestId !== writeRequestId) {
|
|
8273
|
+
const authority = { ...(body.authorityRefused === true || body.authorityRefused === false ? { authorityRefused: body.authorityRefused } : {}),
|
|
8274
|
+
...(typeof body.authorityScope === "string" ? { authorityScope: body.authorityScope } : {}), ...(typeof body.refusalAudited === "boolean" ? { refusalAudited: body.refusalAudited } : {}) };
|
|
8275
|
+
body = { ...body, ...toolWriteUnknownReply(msg, writeRequestId, writeRefusal ?? "caller authority changed before the result could be returned"), ...authority };
|
|
8276
|
+
}
|
|
7968
8277
|
if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify({ rid: msg.rid, ...body }));
|
|
8278
|
+
};
|
|
8279
|
+
try {
|
|
8280
|
+
controlWriteSpan("daemon-reply", sendReply);
|
|
8281
|
+
} finally { finishControlWrite(); }
|
|
8282
|
+
};
|
|
8283
|
+
const runTaskRequest = (run: () => Promise<string>, extraCurrent: () => void = () => {}, formatError: (error: unknown) => Record<string, unknown> = error => ({ t: "task", ok: false, error: error instanceof Error ? error.message : "task write refused" })): void => {
|
|
8284
|
+
const operation = toolWriteOperation(msg);
|
|
8285
|
+
const payload = { t: "task", op: msg.op, args: msg.args ?? {} };
|
|
8286
|
+
const actor = c.peer ?? USER, args = payload.args && typeof payload.args === "object" && !Array.isArray(payload.args) ? payload.args as Record<string, unknown> : {};
|
|
8287
|
+
const knownPreEffectRefusal = operation !== undefined && (
|
|
8288
|
+
readOnlyPeer(actor) && !READ_ONLY_HUB_TOOLS.has(operation)
|
|
8289
|
+
|| operation === "hub_task_propose" && (!may(actor, "propose") || (() => {
|
|
8290
|
+
const owner = typeof args.owner === "string" ? args.owner.trim().toLowerCase() : "";
|
|
8291
|
+
return !!owner && PEER_ID.test(owner) && ![actor, USER, HUB, "digest"].includes(owner) && !may(actor, "assign");
|
|
8292
|
+
})())
|
|
8293
|
+
|| operation === "hub_review" && actor !== USER && (() => {
|
|
8294
|
+
const id = taskWriteId(args.id);
|
|
8295
|
+
if (id === undefined || id < 1) return false;
|
|
8296
|
+
const task = board.get(id);
|
|
8297
|
+
return !!task && task.reviewer !== actor;
|
|
8298
|
+
})()
|
|
8299
|
+
);
|
|
8300
|
+
if (!operation || msg.requestId === undefined || knownPreEffectRefusal) {
|
|
8301
|
+
void run().then(text => reply({ t: "task", ok: true, text }), error => reply(formatError(error)));
|
|
8302
|
+
return;
|
|
8303
|
+
}
|
|
8304
|
+
const scope = toolWriteScope(c, operation);
|
|
8305
|
+
void toolWriteRegistry.run({ id: msg.requestId, scope, bucket: actor, payload,
|
|
8306
|
+
current: result => toolWriteCurrent(c, sock, operation, payload, scope, result) && (() => { try { extraCurrent(); return true; } catch { return false; } })(),
|
|
8307
|
+
}, async () => {
|
|
8308
|
+
try { return { t: "task", ok: true, text: await run() }; }
|
|
8309
|
+
catch (error) {
|
|
8310
|
+
if (error instanceof PreEffectToolRefusal || error instanceof OperationalAuthorityError) return formatError(error);
|
|
8311
|
+
throw error;
|
|
8312
|
+
}
|
|
8313
|
+
}).then(result => reply(result), () => reply(toolWriteUnknownReply(payload, typeof msg.requestId === "string" ? msg.requestId : undefined)));
|
|
7969
8314
|
};
|
|
7970
8315
|
if (!c.authed) {
|
|
7971
8316
|
if (msg.t !== "hello" || msg.token !== token) return sock.close(4401, "bad token");
|
|
@@ -8186,7 +8531,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8186
8531
|
if(c.role!=="console" && msg.reason!==undefined && (typeof msg.reason!=="string" || !tasks.publicMetadataText(msg.reason)))return void reply({ok:false,error:"queue reason withheld by privacy policy"});
|
|
8187
8532
|
if (msg.op === "list") {
|
|
8188
8533
|
if (msg.peer !== undefined && (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer))) return void reply({ ok: false, error: "invalid peer" });
|
|
8189
|
-
return void reply({ ok:
|
|
8534
|
+
if (msg.all !== undefined && typeof msg.all !== "boolean") return void reply({ ok: false, error: "all is true or false" });
|
|
8535
|
+
// #1096: a row an operator action already settled (a retry parent left `failed` for good, a discarded or completed row) is history, not open work: hidden unless `all`.
|
|
8536
|
+
const everything = bus.queueList(msg.peer), settled = msg.all === true ? new Set<string>() : bus.settledIds();
|
|
8537
|
+
const shown = everything.filter((row) => !settled.has(row.id));
|
|
8538
|
+
return void reply({ ok: true, deliveries: shown.map((row) => queueView(row,false,c.role!=="console")), ...(shown.length < everything.length ? { hiddenSettled: everything.length - shown.length } : {}) });
|
|
8190
8539
|
}
|
|
8191
8540
|
if (msg.op === "discard") {
|
|
8192
8541
|
// #713: the person's bulk clear of one peer's backlog. Without `confirmed` it only counts; with it, every queued (or needs_review) delivery of that peer is discarded
|
|
@@ -8194,10 +8543,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8194
8543
|
// because only the named peer is read. Refused while recovery or shutdown holds queue mutations, like every queue resolution.
|
|
8195
8544
|
if (typeof msg.peer !== "string" || !PEER_ID.test(msg.peer) && !parseRemotePeerId(msg.peer)) return void reply({ ok: false, error: "peer must be a peer id" });
|
|
8196
8545
|
if (msg.peer.includes("@") && c.role !== "console") return void reply({ ok: false, error: "remote delivery disposition is person-only" });
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
if (msg.
|
|
8546
|
+
// #1096: `states` (a list of queued, needs_review, failed) clears several kinds in one call, and a `failed` row counts only while no resolution settled it. The older single `state` keeps its meaning.
|
|
8547
|
+
const KINDS = ["queued", "needs_review", "failed"] as const;
|
|
8548
|
+
if (msg.states !== undefined && msg.state !== undefined) return void reply({ ok: false, error: "give state or states, not both" });
|
|
8549
|
+
if (msg.states !== undefined && (!Array.isArray(msg.states) || !msg.states.length || msg.states.some((entry: unknown) => !(KINDS as readonly unknown[]).includes(entry)) || new Set(msg.states).size !== msg.states.length)) return void reply({ ok: false, error: `states is a list of ${KINDS.join(", ")}` });
|
|
8550
|
+
if (msg.states === undefined && msg.state !== undefined && msg.state !== "queued" && msg.state !== "needs_review") return void reply({ ok: false, error: "state is queued or needs_review" });
|
|
8551
|
+
const wanted: readonly string[] = msg.states ?? [msg.state === undefined ? "queued" : msg.state];
|
|
8552
|
+
const settledRows = wanted.includes("failed") ? bus.settledIds() : new Set<string>();
|
|
8553
|
+
const rows = bus.queueList(msg.peer).filter((row) => wanted.includes(row.state) && !(row.state === "failed" && settledRows.has(row.id)));
|
|
8554
|
+
const byState = Object.fromEntries(wanted.map((kind) => [kind, rows.filter((row) => row.state === kind).length]));
|
|
8555
|
+
const state = wanted.length === 1 ? wanted[0]! : wanted.join("+");
|
|
8556
|
+
const many = msg.states !== undefined ? { states: wanted, byState } : {};
|
|
8557
|
+
if (msg.confirmed !== true) return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded: 0, refused: 0 });
|
|
8201
8558
|
if (typeof msg.reason !== "string" || !msg.reason.trim() || msg.reason.length > 2000) return void reply({ ok: false, error: "discard needs a reason" });
|
|
8202
8559
|
if (recoveryActive() || stopping) return void reply({ ok: false, error: "recovery or shutdown is holding queue mutations" });
|
|
8203
8560
|
let discarded = 0; const refused: Record<string, number> = {};
|
|
@@ -8206,7 +8563,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8206
8563
|
// discarding it, so the 12th discard is not refused as stale because of the 11 before it. A row that is gone or changed state meanwhile is counted as refused.
|
|
8207
8564
|
const current = bus.queueShow(row.id) ?? bus.queueList(msg.peer).find((r) => r.id === row.id);
|
|
8208
8565
|
try {
|
|
8209
|
-
if (!current || current.state !== state) throw new Error("unknown delivery");
|
|
8566
|
+
if (!current || current.state !== row.state) throw new Error("unknown delivery");
|
|
8210
8567
|
bus.resolveDelivery(row.id, current.revision, "discard", msg.reason.trim());
|
|
8211
8568
|
if (row.peer.includes("@")) roomChat.disposition(row.peer, row.id, "discard");
|
|
8212
8569
|
discarded++;
|
|
@@ -8214,7 +8571,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8214
8571
|
catch (error) { const why = resolutionRefusal(error).split(":")[0]!; refused[why] = (refused[why] ?? 0) + 1; }
|
|
8215
8572
|
}
|
|
8216
8573
|
writeStatus();
|
|
8217
|
-
return void reply({ ok: true, op: "discard", peer: msg.peer, state, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
|
|
8574
|
+
return void reply({ ok: true, op: "discard", peer: msg.peer, state, ...many, count: rows.length, discarded, refused: rows.length - discarded, ...(rows.length - discarded ? { refusedBy: refused } : {}) });
|
|
8218
8575
|
}
|
|
8219
8576
|
if (typeof msg.id !== "string" || !msg.id || msg.id.length > 256) return void reply({ ok: false, error: "invalid delivery ID" });
|
|
8220
8577
|
if (msg.op === "show") {
|
|
@@ -8248,6 +8605,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8248
8605
|
return void reply({ ok: true });
|
|
8249
8606
|
}
|
|
8250
8607
|
case "inbox": {
|
|
8608
|
+
if (stopping || recoveryActive()) return void reply({ ok: false, error: "the hub is not serving inbox reads now (recovery or shutdown); run ahub status, then call hub_inbox again after it recovers" });
|
|
8251
8609
|
if (c.toolsOwner) {
|
|
8252
8610
|
const peer = c.peer!, current = captureImplementerTools(c), launch = currentImplementerTools(c);
|
|
8253
8611
|
try {
|
|
@@ -8255,9 +8613,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8255
8613
|
const pending = bus.pendingEnvelopes(peer);
|
|
8256
8614
|
if (!current() || pending.some(env => { const task = env.refs?.task === undefined ? undefined : board.get(Number(env.refs.task)); return env.private || contextPii(peer) || !!task && tasks.isPii(task); })) return void reply({ ok: false, error: "private inbox delivery refused" });
|
|
8257
8615
|
if (!launch || pending.some(env => (env.refs?.task === undefined ? implementerChatTask(peer) : Number(env.refs.task)) !== launch.task)) return void reply({ ok: false, error: "inbox delivery belongs to another task session" });
|
|
8258
|
-
const envs = bus.pull(peer);
|
|
8616
|
+
const envs = bus.pull(peer, { allowNeedsReviewHold: true });
|
|
8259
8617
|
if (!envs) return void reply({ ok: false, error: queueHold(peer) ?? "delivery held" });
|
|
8260
|
-
return void reply({ ok: true, envs, waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
|
|
8618
|
+
return void reply({ ok: true, envs, held: inboxHeldMetadata(peer), waiting: bus.queued(peer), waitingImportant: bus.queuedImportant(peer) });
|
|
8261
8619
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
8262
8620
|
}
|
|
8263
8621
|
// The attached session can explicitly claim queued work even during a native turn (#493).
|
|
@@ -8265,7 +8623,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8265
8623
|
const peer = c.peer ? bus.peers.get(c.peer) : undefined;
|
|
8266
8624
|
if (c.role !== "peer" || !isWsPeer(peer) || !peer.owns(sock)) return void reply({ ok: false, error: "not attached to the hub yet; retry hub_inbox in a moment" });
|
|
8267
8625
|
try {
|
|
8268
|
-
const envs = bus.pull(peer.id);
|
|
8626
|
+
const envs = bus.pull(peer.id, { allowNeedsReviewHold: true });
|
|
8269
8627
|
if (!envs) return void reply({ ok: false, error: queueHold(peer.id) ?? (bus.isRecoveryHeld ? "recovery is holding deliveries" : pauseReason(peer.id)) });
|
|
8270
8628
|
peer.lastInboxAt = Date.now();
|
|
8271
8629
|
let polling: ReturnType<InboxPolling["read"]>;
|
|
@@ -8276,7 +8634,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8276
8634
|
} catch { polling = undefined; } // The pull already completed: advice must never discard its envelopes.
|
|
8277
8635
|
writeStatus();
|
|
8278
8636
|
if (envs.length) log(`${peer.id} read ${envs.length} queued message(s) through hub_inbox`);
|
|
8279
|
-
return void reply({ ok: true, envs, waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
|
|
8637
|
+
return void reply({ ok: true, envs, held: inboxHeldMetadata(peer.id), waiting: bus.queued(peer.id), waitingImportant: bus.queuedImportant(peer.id), ...(polling ? { pollingAdvice: { count: polling.count, minutes: polling.minutes } } : {}) });
|
|
8280
8638
|
} catch { return void reply({ ok: false, error: "delivery journal unavailable" }); }
|
|
8281
8639
|
}
|
|
8282
8640
|
case "delivery_complete": {
|
|
@@ -8959,8 +9317,18 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
8959
9317
|
}
|
|
8960
9318
|
return void reply({ ok: false, error: "unknown room operation" });
|
|
8961
9319
|
}
|
|
8962
|
-
case "status":
|
|
8963
|
-
|
|
9320
|
+
case "status": {
|
|
9321
|
+
const current = status(c.role === "console", false, true);
|
|
9322
|
+
if ((c.role === "console" || c.role === "tools") && msg.specs === true) {
|
|
9323
|
+
const summary = projectSpecs.status();
|
|
9324
|
+
if (summary) {
|
|
9325
|
+
const missingBlockHint = !specsMissingBlockHintShown && projectSpecs.missingBlock() === true;
|
|
9326
|
+
if (missingBlockHint) specsMissingBlockHintShown = true;
|
|
9327
|
+
return void reply({ t: "status", status: { ...current, specs: { ...summary, ...(missingBlockHint ? { missingBlockHint: true } : {}) } } });
|
|
9328
|
+
}
|
|
9329
|
+
}
|
|
9330
|
+
return void reply({ t: "status", status: current });
|
|
9331
|
+
}
|
|
8964
9332
|
case "cli_audit_ack": {
|
|
8965
9333
|
if (c.role !== "console") return void reply({ ok: false, error: "cli_audit_ack is a console command" });
|
|
8966
9334
|
if (typeof msg.auditId !== "string" || !/^[a-f0-9-]{36}$/.test(msg.auditId)) return void reply({ ok: false, error: "invalid audit id" });
|
|
@@ -9036,8 +9404,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9036
9404
|
case "native_tui_launch": {
|
|
9037
9405
|
if (c.role !== "console") return void reply({ ok: false, error: "native TUI launch is console only" });
|
|
9038
9406
|
const peer = String(msg.peer), owner = bus.peers.get(peer);
|
|
9039
|
-
|
|
9040
|
-
|
|
9407
|
+
// #1085: every refusal names its cause class, in the reply and as one hub.log line. The class is a closed word: never a session id, launch id, socket or path.
|
|
9408
|
+
const refuseTui = (code: string, error: string, resumed?: boolean) => { log(`native_tui_launch jcode refused: ${code}${resumed ? " (resumed session)" : ""}`); return void reply({ ok: false, code, ...(resumed ? { resumed: true } : {}), error }); };
|
|
9409
|
+
if (stopping || recoveryActive() || !membershipAllowed(peer) || rosterChanging === peer || requestedPeerStops.has(peer)) return refuseTui("hub_busy", "owned native TUI launch is unavailable");
|
|
9410
|
+
if (peer !== definitionOf(peer)?.kind || !isJcodePeer(owner)) return refuseTui("not_running", "owned native TUI launch is unavailable");
|
|
9411
|
+
if (!jcodeMcpReady(opts.cwd, opts.stateDir, SERVER_JS)) return refuseTui("mcp_stale", "owned native TUI launch is unavailable");
|
|
9041
9412
|
try {
|
|
9042
9413
|
if (msg.binding !== undefined && (!msg.binding || typeof msg.binding.launchId !== "string" || typeof msg.binding.sessionId !== "string")) throw new Error("invalid binding");
|
|
9043
9414
|
const options = msg.args ?? {};
|
|
@@ -9045,7 +9416,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9045
9416
|
return void reply({ ok: true, ...owner.tuiLaunch(msg.binding, options) });
|
|
9046
9417
|
} catch (error) {
|
|
9047
9418
|
if (error instanceof JcodeOptionMismatch) return void reply({ ok: false, option: error.option, error: `owned Jcode launch differs from its running session in ${error.option}; start without it or stop jcode first` });
|
|
9048
|
-
|
|
9419
|
+
if (error instanceof JcodeTuiRefusal) return refuseTui(error.code, "owned Jcode launch or saved session could not be verified; details withheld", owner.resumedSession);
|
|
9420
|
+
return refuseTui("bad_request", "owned Jcode launch or saved session could not be verified; details withheld");
|
|
9049
9421
|
}
|
|
9050
9422
|
}
|
|
9051
9423
|
case "peer_start":
|
|
@@ -9060,7 +9432,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9060
9432
|
return;
|
|
9061
9433
|
case "start":
|
|
9062
9434
|
if (c.role !== "console") return void reply({ t: "started", ok: false, error: "start is a console command; use hub_peer_start with the conductor role" });
|
|
9063
|
-
if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error:
|
|
9435
|
+
if (recoveryActive() && !(recoveryPhase === "restored" && msg.operationId === recoveryOperationId)) return void reply({ t: "started", ok: false, error: startHeldByRecovery(msg.peer) });
|
|
9064
9436
|
if (recoveryActive() && !recoveryPeerAllowed(String(msg.peer))) return void reply({ t: "started", ok: false, error: "peer is not part of the recovery roster" });
|
|
9065
9437
|
startPeer(String(msg.peer), msg.args ?? {}, msg.args?.replaceSession === true ? piReplacementAuthority : undefined)
|
|
9066
9438
|
.catch((e: Error) => ({ ok: false, error: e.message }))
|
|
@@ -9093,10 +9465,8 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9093
9465
|
// #933: a console task op the conductor may run: admitted (role recheck and one audit row) before Tasks sees it; Tasks asks `operational` again.
|
|
9094
9466
|
let current: () => void;
|
|
9095
9467
|
try { current = admitOperational(sock, CONDUCTOR_TASK_OPS[String(msg.op)]!); } catch (error) { return void reply({ t: "task", ...operationalFailure(error) }); }
|
|
9096
|
-
|
|
9097
|
-
|
|
9098
|
-
(e: Error) => reply({ t: "task", ...operationalFailure(e) }),
|
|
9099
|
-
);
|
|
9468
|
+
runTaskRequest(() => operationalContext.run({ actor: c.peer!, current }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})), current,
|
|
9469
|
+
error => ({ t: "task", ...operationalFailure(error) }));
|
|
9100
9470
|
return;
|
|
9101
9471
|
}
|
|
9102
9472
|
let invocation: AbortController | undefined;
|
|
@@ -9109,14 +9479,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9109
9479
|
invocation = new AbortController(); c.invocationRequests.set(msg.rid, invocation);
|
|
9110
9480
|
}
|
|
9111
9481
|
const run = async () => c.externalOwner ? childToolAuthority.run({ by: c.peer!, current: () => currentExternalTools(c, true) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : c.toolsOwner ? childToolAuthority.run({ by: c.peer!, current: captureImplementerTools(c) }, () => taskOp(c.peer!, String(msg.op), msg.args ?? {})) : taskOp(c.peer ?? USER, String(msg.op), msg.args ?? {});
|
|
9112
|
-
(invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
|
|
9482
|
+
const execute = () => (invocation ? invocationRequestSignal.run({ signal: invocation.signal, current: () => {
|
|
9113
9483
|
const live = !c.closed && sock.readyState === WebSocket.OPEN && c.invocationRequests?.get(msg.rid) === invocation;
|
|
9114
9484
|
if (!live) invocation?.abort();
|
|
9115
9485
|
return live;
|
|
9116
|
-
} }, run) : run())
|
|
9117
|
-
|
|
9118
|
-
(
|
|
9119
|
-
|
|
9486
|
+
} }, run) : run());
|
|
9487
|
+
if (invocation) void execute().then(text => reply({ t: "task", ok: true, text }), (error: Error) => reply({ t: "task", ok: false, error: error.message }))
|
|
9488
|
+
.finally(() => { if (c.invocationRequests?.get(msg.rid) === invocation) c.invocationRequests.delete(msg.rid); });
|
|
9489
|
+
else runTaskRequest(execute);
|
|
9120
9490
|
return;
|
|
9121
9491
|
case "pause":
|
|
9122
9492
|
case "resume": {
|
|
@@ -9300,6 +9670,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9300
9670
|
}
|
|
9301
9671
|
async function stopOnce(): Promise<void> {
|
|
9302
9672
|
stopping = true;
|
|
9673
|
+
controlWriteTelemetry?.stop();
|
|
9303
9674
|
roomCommands.stop();
|
|
9304
9675
|
tasks.stopQueuePressureRechecks();
|
|
9305
9676
|
const studiesStopped = tasks.stopStudies();
|
|
@@ -9399,6 +9770,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
9399
9770
|
bus.onQueues(); // initial restored holds are reported only after the listener is bound; writes status.json too
|
|
9400
9771
|
log(`${RUN_START}${process.pid} control=127.0.0.1:${server.port} cwd=${opts.cwd}`);
|
|
9401
9772
|
ready = true;
|
|
9773
|
+
controlWriteTelemetry?.start();
|
|
9402
9774
|
handoffCatchUp();
|
|
9403
9775
|
tasks.scheduleQueuePressureRecheck();
|
|
9404
9776
|
for (const name of opts.configWatch === false ? [] : CONFIG_SOURCE_FILES) {
|