@animalabs/agent-framework 0.29.0 → 0.31.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/README.md +71 -8
- package/dist/src/admission/admit.d.ts +90 -0
- package/dist/src/admission/admit.d.ts.map +1 -0
- package/dist/src/admission/admit.js +43 -0
- package/dist/src/admission/admit.js.map +1 -0
- package/dist/src/agent.d.ts +50 -9
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +125 -23
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server-core.d.ts.map +1 -1
- package/dist/src/api/mcp-server-core.js +10 -3
- package/dist/src/api/mcp-server-core.js.map +1 -1
- package/dist/src/author-presentation.d.ts +50 -0
- package/dist/src/author-presentation.d.ts.map +1 -0
- package/dist/src/author-presentation.js +58 -0
- package/dist/src/author-presentation.js.map +1 -0
- package/dist/src/code-execution/isolate/render.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/render.js +7 -6
- package/dist/src/code-execution/isolate/render.js.map +1 -1
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +5 -4
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/dendrite/index.d.ts +6 -0
- package/dist/src/dendrite/index.d.ts.map +1 -0
- package/dist/src/dendrite/index.js +3 -0
- package/dist/src/dendrite/index.js.map +1 -0
- package/dist/src/dendrite/presets.d.ts +60 -0
- package/dist/src/dendrite/presets.d.ts.map +1 -0
- package/dist/src/dendrite/presets.js +117 -0
- package/dist/src/dendrite/presets.js.map +1 -0
- package/dist/src/dendrite/registry.d.ts +260 -0
- package/dist/src/dendrite/registry.d.ts.map +1 -0
- package/dist/src/dendrite/registry.js +742 -0
- package/dist/src/dendrite/registry.js.map +1 -0
- package/dist/src/dendrite/types.d.ts +392 -0
- package/dist/src/dendrite/types.d.ts.map +1 -0
- package/dist/src/dendrite/types.js +24 -0
- package/dist/src/dendrite/types.js.map +1 -0
- package/dist/src/framework.d.ts +806 -118
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +4180 -1121
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +36 -21
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +223 -149
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/recipe-reconcile.d.ts.map +1 -1
- package/dist/src/gate/recipe-reconcile.js +6 -5
- package/dist/src/gate/recipe-reconcile.js.map +1 -1
- package/dist/src/host-presentation.d.ts +4 -0
- package/dist/src/host-presentation.d.ts.map +1 -1
- package/dist/src/host-presentation.js +12 -10
- package/dist/src/host-presentation.js.map +1 -1
- package/dist/src/index.d.ts +6 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +7 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/inference-bounds.d.ts +37 -0
- package/dist/src/inference-bounds.d.ts.map +1 -0
- package/dist/src/inference-bounds.js +84 -0
- package/dist/src/inference-bounds.js.map +1 -0
- package/dist/src/line-reader.d.ts +7 -1
- package/dist/src/line-reader.d.ts.map +1 -1
- package/dist/src/line-reader.js +17 -6
- package/dist/src/line-reader.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -9
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +136 -58
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/errors.d.ts +1 -0
- package/dist/src/mcpl/errors.d.ts.map +1 -1
- package/dist/src/mcpl/errors.js +3 -0
- package/dist/src/mcpl/errors.js.map +1 -1
- package/dist/src/mcpl/inbound-source.d.ts +2 -2
- package/dist/src/mcpl/inbound-source.js +2 -2
- package/dist/src/mcpl/inference-router.d.ts +11 -1
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +65 -9
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/modern-connection.d.ts +10 -7
- package/dist/src/mcpl/modern-connection.d.ts.map +1 -1
- package/dist/src/mcpl/modern-connection.js +85 -74
- package/dist/src/mcpl/modern-connection.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts +1 -1
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +45 -3
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +11 -7
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +21 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +1 -2
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +3 -3
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +14 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +26 -22
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +1 -0
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -1
- package/dist/src/mcpl/tool-signature.js +16 -11
- package/dist/src/mcpl/tool-signature.js.map +1 -1
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -1
- package/dist/src/mcpl/toolsets-module.js +7 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +44 -21
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +283 -64
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +17 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +32 -4
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +189 -25
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
- package/dist/src/modules/discord/discord-js-client.js +24 -16
- package/dist/src/modules/discord/discord-js-client.js.map +1 -1
- package/dist/src/modules/sender-name.d.ts +5 -4
- package/dist/src/modules/sender-name.d.ts.map +1 -1
- package/dist/src/modules/sender-name.js +7 -5
- package/dist/src/modules/sender-name.js.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.d.ts +16 -8
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
- package/dist/src/modules/workspace/disk-agreement.js +33 -12
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +6 -3
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +61 -15
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +9 -1
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +50 -10
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts +11 -1
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +19 -8
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +8 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +7 -0
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +7 -5
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/notice-tags.d.ts +2 -0
- package/dist/src/notice-tags.d.ts.map +1 -1
- package/dist/src/notice-tags.js +2 -0
- package/dist/src/notice-tags.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.js +1 -9
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
- package/dist/src/silent-heartbeat.d.ts +4 -2
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js +7 -4
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stored-injections.d.ts +4 -0
- package/dist/src/stored-injections.d.ts.map +1 -1
- package/dist/src/stored-injections.js +6 -0
- package/dist/src/stored-injections.js.map +1 -1
- package/dist/src/supplied-text.d.ts +2 -0
- package/dist/src/supplied-text.d.ts.map +1 -1
- package/dist/src/supplied-text.js +16 -7
- package/dist/src/supplied-text.js.map +1 -1
- package/dist/src/tool-result-guard.js +1 -1
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +368 -16
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +1224 -136
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/tune-out/tools.d.ts +115 -7
- package/dist/src/tune-out/tools.d.ts.map +1 -1
- package/dist/src/tune-out/tools.js +55 -18
- package/dist/src/tune-out/tools.js.map +1 -1
- package/dist/src/types/agent.d.ts +27 -1
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +153 -15
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +42 -3
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +140 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/admission/admit.ts +113 -0
- package/src/agent.ts +138 -31
- package/src/api/mcp-server-core.ts +10 -3
- package/src/author-presentation.ts +81 -0
- package/src/code-execution/isolate/render.ts +7 -6
- package/src/code-execution/isolate/scripts.ts +5 -4
- package/src/dendrite/index.ts +32 -0
- package/src/dendrite/presets.ts +149 -0
- package/src/dendrite/registry.ts +808 -0
- package/src/dendrite/types.ts +397 -0
- package/src/framework.ts +4344 -1052
- package/src/gate/event-gate.ts +251 -150
- package/src/gate/recipe-reconcile.ts +7 -5
- package/src/host-presentation.ts +14 -10
- package/src/index.ts +16 -2
- package/src/inference-bounds.ts +111 -0
- package/src/line-reader.ts +22 -5
- package/src/mcpl/channel-registry.ts +170 -60
- package/src/mcpl/errors.ts +4 -0
- package/src/mcpl/inbound-source.ts +2 -2
- package/src/mcpl/inference-router.ts +67 -10
- package/src/mcpl/modern-connection.ts +83 -68
- package/src/mcpl/protocol-family.ts +37 -4
- package/src/mcpl/push-coalescer.ts +21 -7
- package/src/mcpl/references.ts +3 -4
- package/src/mcpl/server-connection.ts +30 -21
- package/src/mcpl/tool-signature.ts +18 -11
- package/src/mcpl/toolsets-module.ts +7 -0
- package/src/mcpl/transport.ts +261 -60
- package/src/mcpl/types.ts +18 -1
- package/src/module-registry.ts +178 -25
- package/src/modules/discord/discord-js-client.ts +33 -29
- package/src/modules/sender-name.ts +8 -5
- package/src/modules/workspace/disk-agreement.ts +39 -15
- package/src/modules/workspace/index.ts +65 -14
- package/src/modules/workspace/observe.ts +51 -10
- package/src/modules/workspace/reconcile.ts +22 -8
- package/src/modules/workspace/types.ts +5 -0
- package/src/modules/workspace/watcher.ts +8 -6
- package/src/notice-tags.ts +2 -0
- package/src/recovery/discord-awareness-outbox.ts +1 -11
- package/src/silent-heartbeat.ts +7 -4
- package/src/stored-injections.ts +7 -0
- package/src/supplied-text.ts +17 -6
- package/src/tool-result-guard.ts +1 -1
- package/src/tune-out/coordinator.ts +1374 -141
- package/src/tune-out/tools.ts +166 -13
- package/src/types/agent.ts +30 -1
- package/src/types/framework.ts +157 -15
- package/src/types/module.ts +42 -3
- package/src/types/trace.ts +154 -3
|
@@ -42,7 +42,15 @@ import { Refusal, onlyTheHost } from './answers.js';
|
|
|
42
42
|
const UNTIL_REOPENED = "the workspace can't check its files against disk until its store is reopened";
|
|
43
43
|
const REOPEN = onlyTheHost('reopen it with a restart');
|
|
44
44
|
|
|
45
|
-
export class EvidenceUnavailable extends Refusal {
|
|
45
|
+
export class EvidenceUnavailable extends Refusal {
|
|
46
|
+
constructor(readonly why: string) {
|
|
47
|
+
super(`${UNTIL_REOPENED}: ${why}. ${REOPEN}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function rootUnreadable(err: EvidenceUnavailable): string {
|
|
52
|
+
return `the root disk last agreed with can't be read until the store is reopened: ${err.why}. ${REOPEN}`;
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
export const callGate = new AsyncLocalStorage<{ passed: boolean }>();
|
|
48
56
|
|
|
@@ -105,8 +113,8 @@ export type Physical = Agreed | Pending | Interrupted;
|
|
|
105
113
|
|
|
106
114
|
/**
|
|
107
115
|
* Which directory a mount root is: its device and inode, and its birth time
|
|
108
|
-
* in nanoseconds as the runtime reports it, where that is above zero
|
|
109
|
-
* strings.
|
|
116
|
+
* in nanoseconds as the runtime reports it, where that is above zero and isn't
|
|
117
|
+
* its ctime, as decimal strings.
|
|
110
118
|
*/
|
|
111
119
|
export interface RootIdentity {
|
|
112
120
|
dev: string;
|
|
@@ -121,16 +129,32 @@ function sameRootPath(a: string, b: string): boolean {
|
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
/**
|
|
124
|
-
* Whether
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
132
|
+
* Whether the directory seen `now` is the one `recorded`: the same inode, and
|
|
133
|
+
* not born after the recorded one where `now`'s filesystem keeps a birth time
|
|
134
|
+
* fixed (`bornFixed`), since ext4 gives a directory made where one was removed
|
|
135
|
+
* its inode again, while overlayfs gives a directory copied up from a lower
|
|
136
|
+
* layer a later birth time under the same inode; and the same device, or the
|
|
137
|
+
* same birth time on another device, since macOS can give a volume another
|
|
138
|
+
* device number after a reboot. A birth time in whole seconds doesn't count
|
|
139
|
+
* across devices: filesystems made in the same second can give their roots
|
|
140
|
+
* one inode (2 on ext4) and one birth time.
|
|
129
141
|
*/
|
|
130
|
-
export function sameRootIdentity(
|
|
131
|
-
if (
|
|
132
|
-
if (
|
|
133
|
-
return
|
|
142
|
+
export function sameRootIdentity(recorded: RootIdentity, now: RootIdentity, bornFixed: boolean): boolean {
|
|
143
|
+
if (recorded.ino !== now.ino || (bornFixed && bornLater(recorded, now))) return false;
|
|
144
|
+
if (recorded.dev === now.dev) return true;
|
|
145
|
+
return fractional(recorded.born) && recorded.born === now.born;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function onlyDeviceDiffers(recorded: RootIdentity, now: RootIdentity, bornFixed: boolean): boolean {
|
|
149
|
+
return !sameRootIdentity(recorded, now, bornFixed) && recorded.ino === now.ino && recorded.dev !== now.dev && !(bornFixed && bornLater(recorded, now));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function bornLater(recorded: RootIdentity, now: RootIdentity): boolean {
|
|
153
|
+
return recorded.born !== undefined && now.born !== undefined && BigInt(now.born) > BigInt(recorded.born);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function fractional(born: string | undefined): boolean {
|
|
157
|
+
return born !== undefined && !born.endsWith('000000000');
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
type JournalEntry =
|
|
@@ -242,10 +266,10 @@ export class DiskAgreement {
|
|
|
242
266
|
*/
|
|
243
267
|
ensureReconciled(): void {
|
|
244
268
|
if (this.journal.needsReopen) {
|
|
245
|
-
throw new EvidenceUnavailable(
|
|
269
|
+
throw new EvidenceUnavailable('an append to its record of what disk last agreed with failed inside the store, so that record may lack what the append held');
|
|
246
270
|
}
|
|
247
271
|
if (!this.whole) {
|
|
248
|
-
throw new EvidenceUnavailable(
|
|
272
|
+
throw new EvidenceUnavailable('reading back its record of what disk last agreed with failed');
|
|
249
273
|
}
|
|
250
274
|
if (this.journal.needsReconcile) this.rebuild();
|
|
251
275
|
const gate = callGate.getStore();
|
|
@@ -345,7 +369,7 @@ export class DiskAgreement {
|
|
|
345
369
|
recordRootIdentity(mount: string, identity: RootIdentity): void {
|
|
346
370
|
const recorded = this.rootIdentity(mount);
|
|
347
371
|
if (recorded !== undefined) {
|
|
348
|
-
if (!sameRootIdentity(recorded, identity)) return;
|
|
372
|
+
if (!sameRootIdentity(recorded, identity, false)) return;
|
|
349
373
|
const renumbered = recorded.dev !== identity.dev;
|
|
350
374
|
const bornLearned = recorded.born === undefined && identity.born !== undefined;
|
|
351
375
|
if (!renumbered && !bornLearned) return;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { NOTICE_TAG } from '../../notice-tags.js';
|
|
9
|
+
import { nameToTypeBack } from '../../supplied-text.js';
|
|
9
10
|
import { constants as fsConstants } from 'node:fs';
|
|
10
11
|
import type { BigIntStats, Stats } from 'node:fs';
|
|
11
12
|
import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
|
|
@@ -39,9 +40,9 @@ import type {
|
|
|
39
40
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
40
41
|
import { MountWatcher, type FsChange } from './watcher.js';
|
|
41
42
|
import { hashContent, storedBytes, DEFAULT_MAX_FILE_SIZE, type StoredBytes } from './sync.js';
|
|
42
|
-
import { DiskAgreement, EvidenceUnavailable, callGate, sameRootIdentity } from './disk-agreement.js';
|
|
43
|
+
import { DiskAgreement, EvidenceUnavailable, callGate, rootUnreadable, sameRootIdentity, type RootIdentity } from './disk-agreement.js';
|
|
43
44
|
import { type BranchIntent, BranchIntents } from './branch-intent.js';
|
|
44
|
-
import { CHANGED, type Cause, DIRECTORY, IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
|
|
45
|
+
import { CHANGED, type Cause, DIRECTORY, IGNORED, NONBLOCK, OTHER_MOUNT, OUTSIDE_INCLUDE, ROOT_ON_ANOTHER_DEVICE, ROOT_REPLACED, ROOT_UNAVAILABLE, beneathRoot, checkDir, checkRoot, confirmAbsent, coversLanding, directoryAt, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, insideMount, leadsInto, linkOnTheWay, linkRefusal, linkWords, locateRoot, looksBinary, namesHolder, nestedAtReal, nestedGuess, nestedHolder, nestedStep, observePath, parsedSpelling, passedOverAt, pathSegments, patternRefusal, readBytes, readPrefix, realSegments, restBeneath, rootAbsence, sameRoot, shownLeads, sniffImageMime, surelyAtRoot, walkScope, withinIncludes, type CheckedDir, type LinkFound, type MountView, type NestedGuess, type NestedMount, type WorkspaceEntries } from './observe.js';
|
|
45
46
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
|
|
46
47
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
47
48
|
import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
@@ -62,6 +63,7 @@ import {
|
|
|
62
63
|
WALK_CAP,
|
|
63
64
|
entryMode,
|
|
64
65
|
explained,
|
|
66
|
+
passRoot,
|
|
65
67
|
pushPaths,
|
|
66
68
|
reconcilePass,
|
|
67
69
|
settleAdoptionBeforeMutation,
|
|
@@ -122,6 +124,21 @@ const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so th
|
|
|
122
124
|
/** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
|
|
123
125
|
const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
|
|
124
126
|
|
|
127
|
+
const NOTHING_RECORDED_YET = 'nothing is recorded of this root yet, so any directory at its path is taken as it';
|
|
128
|
+
const NOTHING_RECORDED = `${NOTHING_RECORDED_YET}; the first sync that takes in a file, or materialize that writes one, records it`;
|
|
129
|
+
const NOTHING_RECORDED_READ_ONLY = `${NOTHING_RECORDED_YET}; the first sync that takes in a file records it`;
|
|
130
|
+
|
|
131
|
+
const BORN_UNRECORDED = 'no birth time is recorded of this root yet, so a directory made again in its place with the same inode can\'t be told from it; the next sync or ls of this mount records it';
|
|
132
|
+
|
|
133
|
+
interface RootAsShown { dev: string; ino: string; born?: string }
|
|
134
|
+
|
|
135
|
+
function asShown(identity: RootIdentity | null): RootAsShown | null {
|
|
136
|
+
if (identity?.born === undefined) return identity;
|
|
137
|
+
const ns = BigInt(identity.born);
|
|
138
|
+
const second = new Date(Number(ns / 1_000_000_000n) * 1000).toISOString().replace(/\.000Z$/, '');
|
|
139
|
+
return { ...identity, born: `${second}.${String(ns % 1_000_000_000n).padStart(9, '0')}Z` };
|
|
140
|
+
}
|
|
141
|
+
|
|
125
142
|
export type {
|
|
126
143
|
WorkspaceConfig,
|
|
127
144
|
MountConfig,
|
|
@@ -1385,6 +1402,7 @@ export class WorkspaceModule implements Module {
|
|
|
1385
1402
|
/** One disk→store pass; the caller holds the mount's turn. Emits its events. */
|
|
1386
1403
|
private async passUnlocked(mount: MountState, scope: Scope, opts: PassOptions = {}): Promise<PassResult> {
|
|
1387
1404
|
const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
|
|
1405
|
+
if (result.rootListed !== undefined) mount.lastPassRoot = { at: new Date().toISOString(), listed: result.rootListed };
|
|
1388
1406
|
if (scope.kind === 'dir' && scope.dir === '' && scope.recursive) mount.initialSyncDone = true;
|
|
1389
1407
|
// What a pass says is another mount's directory names that mount and
|
|
1390
1408
|
// the path to use there, as the file operations' refusal does: a copy's
|
|
@@ -2300,13 +2318,13 @@ export class WorkspaceModule implements Module {
|
|
|
2300
2318
|
|
|
2301
2319
|
let text: string;
|
|
2302
2320
|
if (paths.length === 1) {
|
|
2303
|
-
text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths[0]}]`;
|
|
2321
|
+
text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${nameToTypeBack(paths[0]!)}]`;
|
|
2304
2322
|
} else {
|
|
2305
|
-
const list = paths.map(p => `- ${p}`).join('\n');
|
|
2323
|
+
const list = paths.map(p => `- ${nameToTypeBack(p)}`).join('\n');
|
|
2306
2324
|
text = `[${NOTICE_TAG.workspaceEvent} · ${op} · ${paths.length} files in ${mountName}/]\n${list}`;
|
|
2307
2325
|
}
|
|
2308
2326
|
if (conflicts && conflicts.length > 0) {
|
|
2309
|
-
text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.join(', ')}; reading each shows how to settle it]`;
|
|
2327
|
+
text += `\n[${NOTICE_TAG.conflictsWithDisk}: ${conflicts.map(nameToTypeBack).join(', ')}; reading each shows how to settle it]`;
|
|
2310
2328
|
}
|
|
2311
2329
|
|
|
2312
2330
|
return {
|
|
@@ -2375,7 +2393,7 @@ export class WorkspaceModule implements Module {
|
|
|
2375
2393
|
/**
|
|
2376
2394
|
* Write binary content (e.g. an image pulled from the agent's context) to a
|
|
2377
2395
|
* mount, through the same Chronicle-tree + auto-materialize path as the
|
|
2378
|
-
* `write` tool. Public API for the framework's synthesized `
|
|
2396
|
+
* `write` tool. Public API for the framework's synthesized `save_recent_image`
|
|
2379
2397
|
* tool and other peer callers that hold bytes rather than text.
|
|
2380
2398
|
*
|
|
2381
2399
|
* `branch`, when given, ties the write to that branch: the store's current
|
|
@@ -3222,8 +3240,9 @@ export class WorkspaceModule implements Module {
|
|
|
3222
3240
|
* observation checks it (checkRoot), so the read and the evidence agree on
|
|
3223
3241
|
* which directory the mount is. Once the workspace has recorded the root's
|
|
3224
3242
|
* identity, a root that resolves to any other directory is refused, in
|
|
3225
|
-
* ROOT_REPLACED's words
|
|
3226
|
-
*
|
|
3243
|
+
* ROOT_REPLACED's words, or ROOT_ON_ANOTHER_DEVICE's where only its device
|
|
3244
|
+
* differs and birth times can't tell; with none recorded yet, it need only
|
|
3245
|
+
* be a directory there. A refusal where no directory is at the root's path
|
|
3227
3246
|
* carries the errno that gives (rootAbsence: ENOENT when nothing is there
|
|
3228
3247
|
* and ENOTDIR when something else is, which a caller can read as no file,
|
|
3229
3248
|
* or the errno the root's stat failed with otherwise, such as EACCES). A
|
|
@@ -4077,9 +4096,7 @@ export class WorkspaceModule implements Module {
|
|
|
4077
4096
|
private async diskLacks(mount: MountState, relativePath: string, path: string, err: unknown): Promise<LostContents['disk']> {
|
|
4078
4097
|
const absent = err instanceof WorkspaceReadError && err.code !== 'mount_unavailable' && (err.errno === 'ENOENT' || err.errno === 'ENOTDIR');
|
|
4079
4098
|
if (!absent) return { unreadable: containedRefusal(err, path) };
|
|
4080
|
-
const
|
|
4081
|
-
const rootReal = await realpath(root).catch(() => root);
|
|
4082
|
-
const view = this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name));
|
|
4099
|
+
const { view, rootReal } = await this.readView(mount);
|
|
4083
4100
|
const proof = await confirmAbsent(view, rootReal, relativePath);
|
|
4084
4101
|
if (!('reason' in proof)) return 'none';
|
|
4085
4102
|
return { unreadable: proof.reason === CHANGED ? containedRefusal(err, path) : proof.reason };
|
|
@@ -5838,6 +5855,8 @@ export class WorkspaceModule implements Module {
|
|
|
5838
5855
|
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
5839
5856
|
return { success: false, error: didNothing('status', this.noSuchMountField(input.mount, 'covers every mount')), isError: true };
|
|
5840
5857
|
}
|
|
5858
|
+
const asked = input.mount === undefined ? [] : [...this.mounts].filter(([name, mount]) => !mount.direct && name === input.mount);
|
|
5859
|
+
const roots = new Map(await Promise.all(asked.map(async ([name, mount]) => [name, await this.rootState(mount)] as const)));
|
|
5841
5860
|
|
|
5842
5861
|
for (const [name, mount] of this.mounts) {
|
|
5843
5862
|
if (input.mount !== undefined && name !== input.mount) continue;
|
|
@@ -5857,6 +5876,8 @@ export class WorkspaceModule implements Module {
|
|
|
5857
5876
|
const pending = new Set([...conflictPaths, ...deletionPaths, ...owed]);
|
|
5858
5877
|
|
|
5859
5878
|
const currentBranch = store.currentBranch();
|
|
5879
|
+
const root = input.mount !== undefined ? roots.get(name)!
|
|
5880
|
+
: mount.lastPassRoot?.listed ? { unavailable: mount.lastPassRoot.listed, lastPassAt: mount.lastPassRoot.at } : undefined;
|
|
5860
5881
|
status[name] = {
|
|
5861
5882
|
path: mount.config.path,
|
|
5862
5883
|
mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
|
|
@@ -5874,14 +5895,44 @@ export class WorkspaceModule implements Module {
|
|
|
5874
5895
|
initialSyncDone: mount.initialSyncDone,
|
|
5875
5896
|
currentBranch: currentBranch.name,
|
|
5876
5897
|
lastMaterializedBranch: mount.lastMaterializedBranchId,
|
|
5877
|
-
canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
|
|
5898
|
+
canMaterialize: this.mountMaterializeBlockReason(store, mount) === null && root?.unavailable === undefined,
|
|
5878
5899
|
...(mount.lastAgentActionScan ? { lastAgentActionScan: mount.lastAgentActionScan } : {}),
|
|
5900
|
+
...(root !== undefined ? { root } : {}),
|
|
5879
5901
|
};
|
|
5880
5902
|
}
|
|
5881
5903
|
|
|
5882
5904
|
return { success: true, data: status };
|
|
5883
5905
|
}
|
|
5884
5906
|
|
|
5907
|
+
private async readView(mount: MountState): Promise<{ view: MountView; rootReal: string }> {
|
|
5908
|
+
const root = resolve(mount.config.path);
|
|
5909
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
5910
|
+
return { view: this.viewOf(mount, this.agreementOrThrow().rootIdentityForRead(mount.config.name)), rootReal };
|
|
5911
|
+
}
|
|
5912
|
+
|
|
5913
|
+
private async rootState(mount: MountState): Promise<{ recorded?: RootAsShown | null; now: RootAsShown | null; unavailable?: string; note?: string }> {
|
|
5914
|
+
const read = await this.readView(mount).catch((err: unknown) => {
|
|
5915
|
+
if (err instanceof EvidenceUnavailable) return err;
|
|
5916
|
+
throw err;
|
|
5917
|
+
});
|
|
5918
|
+
if (read instanceof EvidenceUnavailable) {
|
|
5919
|
+
const root = resolve(mount.config.path);
|
|
5920
|
+
const there = await locateRoot(this.viewOf(mount, undefined), await realpath(root).catch(() => root));
|
|
5921
|
+
return { now: asShown('identity' in there ? there.identity : null), unavailable: rootUnreadable(read) };
|
|
5922
|
+
}
|
|
5923
|
+
const { view, rootReal } = read;
|
|
5924
|
+
const recorded = view.rootIdentity ?? null;
|
|
5925
|
+
const found = await passRoot({ view, rootReal });
|
|
5926
|
+
if ('unavailable' in found) {
|
|
5927
|
+
const there = await locateRoot({ ...view, rootIdentity: undefined }, rootReal);
|
|
5928
|
+
return { recorded: asShown(recorded), now: asShown('identity' in there ? there.identity : null), unavailable: found.listed };
|
|
5929
|
+
}
|
|
5930
|
+
const note = this.agreementOrThrow().awaitsReopen ? undefined
|
|
5931
|
+
: recorded === null ? mount.config.mode === 'read-only' ? NOTHING_RECORDED_READ_ONLY : NOTHING_RECORDED
|
|
5932
|
+
: recorded.born === undefined && found.identity.born !== undefined && found.bornFixed ? BORN_UNRECORDED : undefined;
|
|
5933
|
+
return { recorded: asShown(recorded), now: asShown(found.identity), ...(note !== undefined ? { note } : {}) };
|
|
5934
|
+
}
|
|
5935
|
+
|
|
5885
5936
|
/**
|
|
5886
5937
|
* What the store holds of a mount that disk lacks, on the selected branch,
|
|
5887
5938
|
* among the mount's own paths. Status counts the first three as pending:
|
|
@@ -6401,7 +6452,7 @@ export class WorkspaceModule implements Module {
|
|
|
6401
6452
|
if ('reason' in found) return false; // still missing: the pass says so
|
|
6402
6453
|
const agreement = this.agreementOrThrow();
|
|
6403
6454
|
const recorded = agreement.rootIdentity(mount.config.name);
|
|
6404
|
-
if (recorded && sameRootIdentity(recorded, found.identity)) return false;
|
|
6455
|
+
if (recorded && sameRootIdentity(recorded, found.identity, found.bornFixed)) return false;
|
|
6405
6456
|
if (recorded) agreement.acceptRoot(mount.config.name, found.identity);
|
|
6406
6457
|
else agreement.recordRootIdentity(mount.config.name, found.identity);
|
|
6407
6458
|
agreement.barrier();
|
|
@@ -6868,7 +6919,7 @@ function deletedOnDisk(path: string): string {
|
|
|
6868
6919
|
|
|
6869
6920
|
function uncheckedFile(shown: string, note: string | undefined, cause: Cause): string {
|
|
6870
6921
|
if (note === DIRECTORY) return `${shown} is a directory, not a file; ls ${shown} lists what's in it.`;
|
|
6871
|
-
const on = note === ROOT_REPLACED || cause.ownStep === true ? null
|
|
6922
|
+
const on = note === ROOT_REPLACED || note === ROOT_ON_ANOTHER_DEVICE || cause.ownStep === true ? null
|
|
6872
6923
|
: (note === undefined ? null : wayOn(note, cause, 'read')) ?? 'calling again checks disk again; if the same reason comes back, whoever runs this host can fix it on disk';
|
|
6873
6924
|
return `${shown} isn't in the workspace, and disk couldn't be checked: ${sentence(note ?? 'not observed')}${on !== null ? ` ${on[0]!.toUpperCase()}${sentence(on.slice(1))}` : ''}`;
|
|
6874
6925
|
}
|
|
@@ -46,7 +46,7 @@ import type { BigIntStats } from 'node:fs';
|
|
|
46
46
|
import { lstat, mkdir, open, readdir, readlink, realpath, stat, statfs } from 'node:fs/promises';
|
|
47
47
|
import type { FileHandle } from 'node:fs/promises';
|
|
48
48
|
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path';
|
|
49
|
-
import { sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
|
|
49
|
+
import { onlyDeviceDiffers, sameRootIdentity, type Fingerprint, type RootIdentity } from './disk-agreement.js';
|
|
50
50
|
import { OUTSIDE_STEP, UNFOLLOWED_STEP, errnoClause, errnoCode, systemWords } from './answers.js';
|
|
51
51
|
|
|
52
52
|
export interface MountView {
|
|
@@ -164,8 +164,14 @@ export const LEAVES_MOUNT = 'a symlink that leaves the mount';
|
|
|
164
164
|
export const NOT_A_DIRECTORY = 'a parent path is not a directory';
|
|
165
165
|
export const ROOT_UNAVAILABLE = 'the mount root is unavailable';
|
|
166
166
|
export const ROOT_REPLACED =
|
|
167
|
-
'the mount root is not the directory disk last agreed with (an unmounted drive, or a replaced
|
|
168
|
-
'
|
|
167
|
+
'the mount root is not the directory disk last agreed with (an unmounted drive, a directory moved aside, or a replaced one); ' +
|
|
168
|
+
'whoever runs this host can say which: a drive they can reconnect, a directory moved aside they can put back, ' +
|
|
169
|
+
'and a replaced directory a sync of the mount with acceptRoot takes as it is now';
|
|
170
|
+
export const ROOT_ON_ANOTHER_DEVICE =
|
|
171
|
+
'the mount root has the inode disk last agreed with, but another device number: a reboot can renumber a volume, ' +
|
|
172
|
+
'and another volume mounted at the same place can have a root with the same inode; whoever runs this host can ' +
|
|
173
|
+
'say which, and if it is the same directory, a sync of the mount with acceptRoot takes it as it is now; if not, ' +
|
|
174
|
+
'they can mount the right volume';
|
|
169
175
|
export const CHANGED = 'it changed while it was observed';
|
|
170
176
|
export const CHANGED_WALK = 'the directory changed while the walk ran';
|
|
171
177
|
export const FILE_CHANGED = 'the file changed while it was read';
|
|
@@ -1026,6 +1032,8 @@ export class CheckedDir {
|
|
|
1026
1032
|
private readonly dev: bigint,
|
|
1027
1033
|
private readonly ino: bigint,
|
|
1028
1034
|
private readonly born: bigint,
|
|
1035
|
+
/** For the root: whether its filesystem keeps a birth time fixed (filesystemKeepsBirthTime). */
|
|
1036
|
+
readonly bornFixed = false,
|
|
1029
1037
|
) {}
|
|
1030
1038
|
|
|
1031
1039
|
/** Which directory it is. */
|
|
@@ -1094,7 +1102,12 @@ export async function rootAbsence(root: string): Promise<string | undefined> {
|
|
|
1094
1102
|
async function checkedAt(view: MountView, rel: string, real: string): Promise<CheckedDir | null> {
|
|
1095
1103
|
const info = await lstat(real, { bigint: true }).catch(() => null);
|
|
1096
1104
|
if (!info?.isDirectory()) return null;
|
|
1097
|
-
return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info
|
|
1105
|
+
return new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, bornOf(info), rel === '' && await filesystemKeepsBirthTime(real));
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function bornOf(info: BigIntStats): bigint {
|
|
1109
|
+
// Where statx is unavailable, libuv gives the ctime as the birth time (uv__to_stat).
|
|
1110
|
+
return info.birthtimeNs === info.ctimeNs ? 0n : info.birthtimeNs;
|
|
1098
1111
|
}
|
|
1099
1112
|
|
|
1100
1113
|
/**
|
|
@@ -1141,8 +1154,8 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
|
|
|
1141
1154
|
// A root that isn't the directory disk last agreed under proves nothing
|
|
1142
1155
|
// about the files that agreed: an empty mountpoint where a drive was
|
|
1143
1156
|
// would otherwise read as every file deleted.
|
|
1144
|
-
if (rel === '' && view.rootIdentity && !sameRootIdentity(checked.identity(),
|
|
1145
|
-
return { kind: 'unobserved', reason: ROOT_REPLACED };
|
|
1157
|
+
if (rel === '' && view.rootIdentity && !sameRootIdentity(view.rootIdentity, checked.identity(), checked.bornFixed)) {
|
|
1158
|
+
return { kind: 'unobserved', reason: onlyDeviceDiffers(view.rootIdentity, checked.identity(), checked.bornFixed) ? ROOT_ON_ANOTHER_DEVICE : ROOT_REPLACED };
|
|
1146
1159
|
}
|
|
1147
1160
|
return { kind: 'checked', dir: checked };
|
|
1148
1161
|
}
|
|
@@ -1153,9 +1166,9 @@ async function locateDir(view: MountView, rootReal: string, rel: string): Promis
|
|
|
1153
1166
|
* The mount root as it is now: its identity if it is available (a directory,
|
|
1154
1167
|
* the one the pass was given, and the one disk last agreed under), else why not.
|
|
1155
1168
|
*/
|
|
1156
|
-
export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity } | { reason: string; errno?: string }> {
|
|
1169
|
+
export async function locateRoot(view: MountView, rootReal: string): Promise<{ identity: RootIdentity; bornFixed: boolean } | { reason: string; errno?: string }> {
|
|
1157
1170
|
const found = await checkRoot(view, rootReal);
|
|
1158
|
-
return 'dir' in found ? { identity: found.dir.identity() } : found;
|
|
1171
|
+
return 'dir' in found ? { identity: found.dir.identity(), bornFixed: found.dir.bornFixed } : found;
|
|
1159
1172
|
}
|
|
1160
1173
|
|
|
1161
1174
|
/** The mount root as locateRoot finds it, checked, so a caller can ask later whether it's still there. */
|
|
@@ -1265,7 +1278,7 @@ export async function confirmAbsent(view: MountView, rootReal: string, rel: stri
|
|
|
1265
1278
|
}
|
|
1266
1279
|
if (info.isDirectory()) {
|
|
1267
1280
|
if (last) return { reason: CHANGED }; // present after all
|
|
1268
|
-
cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino, info
|
|
1281
|
+
cur = new CheckedDir(childRel, cur.at(name), lexicalOf(view, childRel), info.dev, info.ino, bornOf(info));
|
|
1269
1282
|
walked.push(cur);
|
|
1270
1283
|
continue;
|
|
1271
1284
|
}
|
|
@@ -1360,6 +1373,34 @@ export async function filesystemTrustsCtime(root: string): Promise<boolean> {
|
|
|
1360
1373
|
}
|
|
1361
1374
|
}
|
|
1362
1375
|
|
|
1376
|
+
/**
|
|
1377
|
+
* Linux filesystems that give a directory its birth time when it is made and
|
|
1378
|
+
* have no call that changes it (statfs f_type). Overlayfs isn't one: copying a
|
|
1379
|
+
* directory up from a lower layer gives it the copy's birth time under the
|
|
1380
|
+
* same device and inode.
|
|
1381
|
+
*/
|
|
1382
|
+
const BIRTH_FIXED_FS_TYPES = new Set<number>([
|
|
1383
|
+
0xef53, // ext2/ext3/ext4
|
|
1384
|
+
0x58465342, // xfs
|
|
1385
|
+
0x9123683e, // btrfs
|
|
1386
|
+
0x01021994, // tmpfs
|
|
1387
|
+
0xf2f52010, // f2fs
|
|
1388
|
+
0x2fc12fc1, // zfs
|
|
1389
|
+
]);
|
|
1390
|
+
|
|
1391
|
+
export function fsTypeKeepsBirthTime(type: number | bigint): boolean {
|
|
1392
|
+
return BIRTH_FIXED_FS_TYPES.has(Number(type) >>> 0);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
export async function filesystemKeepsBirthTime(dir: string): Promise<boolean> {
|
|
1396
|
+
if (process.platform !== 'linux') return false;
|
|
1397
|
+
try {
|
|
1398
|
+
return fsTypeKeepsBirthTime((await statfs(dir)).type);
|
|
1399
|
+
} catch {
|
|
1400
|
+
return false;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1363
1404
|
// ===========================================================================
|
|
1364
1405
|
// Paths and files
|
|
1365
1406
|
// ===========================================================================
|
|
@@ -2106,7 +2147,7 @@ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir,
|
|
|
2106
2147
|
return 'reason' in proof ? { kind: 'unobserved', ...proof } : { kind: 'absent', by: proof.by };
|
|
2107
2148
|
}
|
|
2108
2149
|
if (!info.isDirectory()) return { kind: 'unobserved', reason: CHANGED_WALK };
|
|
2109
|
-
return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, info
|
|
2150
|
+
return { kind: 'checked', dir: new CheckedDir(rel, real, lexicalOf(view, rel), info.dev, info.ino, bornOf(info)) };
|
|
2110
2151
|
}
|
|
2111
2152
|
|
|
2112
2153
|
/**
|
|
@@ -169,6 +169,8 @@ export interface PassResult {
|
|
|
169
169
|
branchId?: string;
|
|
170
170
|
/** Why it decided nothing, where it didn't. */
|
|
171
171
|
undecided?: string;
|
|
172
|
+
/** The words it listed for a root it couldn't use, or null where it used it, from its last look at the root. */
|
|
173
|
+
rootListed?: string | null;
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
export interface MountRuntime {
|
|
@@ -942,6 +944,7 @@ interface Gathered {
|
|
|
942
944
|
branchId: string;
|
|
943
945
|
/** The root the facts were gathered under, when it was available. */
|
|
944
946
|
rootIdentity: RootIdentity | null;
|
|
947
|
+
rootListed: string | null;
|
|
945
948
|
walk: Walk | null;
|
|
946
949
|
facts: Map<string, DiskFact>;
|
|
947
950
|
dirs: string[];
|
|
@@ -951,6 +954,13 @@ interface Gathered {
|
|
|
951
954
|
/** How often a pass gathers again when the branch changes under it. */
|
|
952
955
|
const GATHER_ATTEMPTS = 3;
|
|
953
956
|
|
|
957
|
+
export async function passRoot(mount: Pick<MountRuntime, 'view' | 'rootReal'>): Promise<{ identity: RootIdentity; bornFixed: boolean } | { unavailable: string; cause: Cause; listed: string }> {
|
|
958
|
+
const root = await locateRoot(mount.view, mount.rootReal);
|
|
959
|
+
if ('identity' in root) return root;
|
|
960
|
+
const cause: Cause = root.errno !== undefined ? { errno: root.errno } : {};
|
|
961
|
+
return { unavailable: root.reason, cause, listed: explained(root.reason, cause, 'list') };
|
|
962
|
+
}
|
|
963
|
+
|
|
954
964
|
async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRuntime, scope: Scope, opts: PassOptions): Promise<Gathered> {
|
|
955
965
|
// A missing mount root is an unavailable mount (an unmounted drive, a root
|
|
956
966
|
// being replaced), not proof that every file in it was deleted, and so is a
|
|
@@ -958,9 +968,10 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
|
|
|
958
968
|
// directory a watcher makes where a drive's mountpoint was. The root is
|
|
959
969
|
// the operator's choice and may itself be a symlink to a directory, so it
|
|
960
970
|
// is followed, as `rootReal` is: `followSymlinks` governs links within it.
|
|
961
|
-
const root = await
|
|
962
|
-
const rootUnavailable = '
|
|
963
|
-
const rootCause = '
|
|
971
|
+
const root = await passRoot(mount);
|
|
972
|
+
const rootUnavailable = 'unavailable' in root ? root.unavailable : null;
|
|
973
|
+
const rootCause = 'unavailable' in root ? root.cause : {};
|
|
974
|
+
const rootListed = 'unavailable' in root ? root.listed : null;
|
|
964
975
|
|
|
965
976
|
// The branch is labelled where the tracked candidates are read, with no
|
|
966
977
|
// await between: the decision is checked against this label.
|
|
@@ -968,15 +979,16 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
|
|
|
968
979
|
const gathered: Gathered = {
|
|
969
980
|
branchId,
|
|
970
981
|
rootIdentity: 'identity' in root ? root.identity : null,
|
|
982
|
+
rootListed,
|
|
971
983
|
walk: null,
|
|
972
984
|
facts: new Map(),
|
|
973
985
|
dirs: [],
|
|
974
986
|
incomplete: [],
|
|
975
987
|
};
|
|
976
|
-
if (
|
|
988
|
+
if (rootListed !== null) {
|
|
977
989
|
// Said whether or not anything is tracked: an unavailable mount must not
|
|
978
990
|
// read as a verified empty directory.
|
|
979
|
-
gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason:
|
|
991
|
+
gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: rootListed });
|
|
980
992
|
}
|
|
981
993
|
|
|
982
994
|
// Which paths to decide: what disk shows, and what the store, P and intent
|
|
@@ -1082,14 +1094,15 @@ async function gather(store: JsStore, agreement: DiskAgreement, mount: MountRunt
|
|
|
1082
1094
|
// now, nothing observed counts and nothing is decided from it.
|
|
1083
1095
|
if (gathered.rootIdentity !== null) {
|
|
1084
1096
|
const after = await locateRoot(mount.view, mount.rootReal);
|
|
1085
|
-
if (!('identity' in after) || !sameRootIdentity(after.identity,
|
|
1097
|
+
if (!('identity' in after) || !sameRootIdentity(gathered.rootIdentity, after.identity, after.bornFixed)) {
|
|
1086
1098
|
const reason = 'reason' in after ? after.reason : ROOT_REPLACED;
|
|
1087
1099
|
const errno = 'errno' in after ? after.errno : undefined;
|
|
1088
1100
|
for (const path of gathered.facts.keys()) gathered.facts.set(path, { kind: 'unobserved', reason, ...(errno !== undefined ? { errno } : {}) });
|
|
1089
1101
|
gathered.rootIdentity = null;
|
|
1102
|
+
gathered.rootListed = explained(reason, errno !== undefined ? { errno } : {}, 'list');
|
|
1090
1103
|
gathered.walk = null;
|
|
1091
1104
|
gathered.dirs = [];
|
|
1092
|
-
gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason:
|
|
1105
|
+
gathered.incomplete.push({ path: scope.kind === 'dir' ? scope.dir : '', reason: gathered.rootListed });
|
|
1093
1106
|
}
|
|
1094
1107
|
}
|
|
1095
1108
|
return gathered;
|
|
@@ -1120,6 +1133,7 @@ export async function reconcilePass(
|
|
|
1120
1133
|
let gathered: Gathered | null = null;
|
|
1121
1134
|
for (let attempt = 0; attempt < GATHER_ATTEMPTS; attempt++) {
|
|
1122
1135
|
const next = await gather(store, agreement, mount, scope, opts);
|
|
1136
|
+
result.rootListed = next.rootListed;
|
|
1123
1137
|
if (store.currentBranch().id === next.branchId) {
|
|
1124
1138
|
gathered = next;
|
|
1125
1139
|
break;
|
|
@@ -1469,7 +1483,7 @@ export async function pushPaths(
|
|
|
1469
1483
|
// replaced meanwhile they describe the replacement, so, as a pass does,
|
|
1470
1484
|
// nothing is decided or recorded from them.
|
|
1471
1485
|
const after = await locateRoot(mount.view, mount.rootReal);
|
|
1472
|
-
if (!('identity' in after) || !sameRootIdentity(after.identity,
|
|
1486
|
+
if (!('identity' in after) || !sameRootIdentity(root.identity, after.identity, after.bornFixed)) {
|
|
1473
1487
|
const reason = 'reason' in after ? explained(after.reason, after.errno !== undefined ? { errno: after.errno } : {}, 'write') : ROOT_REPLACED;
|
|
1474
1488
|
for (const path of paths) result.skipped.push({ path, reason });
|
|
1475
1489
|
return result;
|
|
@@ -435,6 +435,11 @@ export interface MountState {
|
|
|
435
435
|
initialSyncDone: boolean;
|
|
436
436
|
/** Branch ID that was active when this mount last materialized */
|
|
437
437
|
lastMaterializedBranchId: string | null;
|
|
438
|
+
/**
|
|
439
|
+
* What the mount's last pass found of its root, as of `at` (an ISO time):
|
|
440
|
+
* the words it listed for a root it couldn't use, or null where it used it.
|
|
441
|
+
*/
|
|
442
|
+
lastPassRoot?: { at: string; listed: string | null };
|
|
438
443
|
/**
|
|
439
444
|
* The last on-agent-action scan, as of `at`: `complete` when it has
|
|
440
445
|
* finished at all, `withinDeadline` when it finished before the deadline
|
|
@@ -38,13 +38,13 @@ export interface WatcherLifecycle {
|
|
|
38
38
|
onReattach?: () => void;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
interface RootIdentity {
|
|
41
|
+
export interface RootIdentity {
|
|
42
42
|
dev: number;
|
|
43
43
|
ino: number;
|
|
44
44
|
birthtimeMs: number;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
const identityOf = (stat: Stats): RootIdentity => ({ dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs });
|
|
47
|
+
export const identityOf = (stat: Stats): RootIdentity => ({ dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs === stat.ctimeMs ? 0 : stat.birthtimeMs });
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Manages filesystem watching for a single mount.
|
|
@@ -481,10 +481,12 @@ export class MountWatcher {
|
|
|
481
481
|
private sameIdentity(a: RootIdentity, b: RootIdentity): boolean {
|
|
482
482
|
// birthtimeMs tells a root made again from a removed one whose inode
|
|
483
483
|
// number it took, unless both were made within one tick of the clock or
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
|
|
484
|
+
// either side's birth time can't be read (0: none kept, or one equal to
|
|
485
|
+
// the ctime, as where statx is unavailable or nothing has changed in the
|
|
486
|
+
// root since it was made). A held root's removal shows in its link count
|
|
487
|
+
// instead, and where the root isn't held the rename's force covers those.
|
|
488
|
+
return a.dev === b.dev && a.ino === b.ino
|
|
489
|
+
&& (a.birthtimeMs === 0 || b.birthtimeMs === 0 || a.birthtimeMs === b.birthtimeMs);
|
|
488
490
|
}
|
|
489
491
|
|
|
490
492
|
private isErrno(err: unknown, code: string): boolean {
|
package/src/notice-tags.ts
CHANGED
|
@@ -33,6 +33,8 @@ export const NOTICE_TAG = {
|
|
|
33
33
|
tuneOutWake: 'Tune-out wake',
|
|
34
34
|
tuneOutCadence: 'Tune-out cadence',
|
|
35
35
|
tuneOutCancelled: 'Tune-out cancelled',
|
|
36
|
+
focus: 'Focus',
|
|
37
|
+
agentLifecycle: 'Agent lifecycle',
|
|
36
38
|
standingDispositions: 'Standing dispositions',
|
|
37
39
|
channelInvitation: 'Channel invitation',
|
|
38
40
|
system: 'system',
|
|
@@ -1285,7 +1285,7 @@ function leftPrepared(batch?: DiscordAwarenessBatch): string {
|
|
|
1285
1285
|
* Read and check the ledger file: a missing file is an empty ledger. A file
|
|
1286
1286
|
* this build can't read for its meaning is refused, never guessed at: one
|
|
1287
1287
|
* it can't read or parse, one that isn't the shape its version promises,
|
|
1288
|
-
* and any version but 2 and 3
|
|
1288
|
+
* and any version but 2 and 3.
|
|
1289
1289
|
*/
|
|
1290
1290
|
function readLedger(path: string):
|
|
1291
1291
|
| { version: 3; state: LedgerState }
|
|
@@ -1318,16 +1318,6 @@ function readLedger(path: string):
|
|
|
1318
1318
|
if (!isLegacyLedger(parsed)) throw new Error(`Invalid Discord awareness ledger ${path}`);
|
|
1319
1319
|
return { version: 2, document: parsed as { version: 2; batches: unknown[] } };
|
|
1320
1320
|
}
|
|
1321
|
-
if (version === 1) {
|
|
1322
|
-
throw new Error(
|
|
1323
|
-
`Discord awareness ledger ${path} is version 1, which only agent-framework commit b16731b2 wrote ` +
|
|
1324
|
-
'(2026-07-15, before any release). Every release rewrites it as version 2 at its next write, and this ' +
|
|
1325
|
-
'build reads versions 2 and 3. Nothing is read from it until the file is moved away. Moving it gives up ' +
|
|
1326
|
-
'only work it still had to send, which this build would retire unsent anyway, since that commit took each ' +
|
|
1327
|
-
'mark out of the file once Discord confirmed it. A mark added just before that process ended, before its ' +
|
|
1328
|
-
'confirmation was written, stays on Discord, and retract can\'t find it',
|
|
1329
|
-
);
|
|
1330
|
-
}
|
|
1331
1321
|
throw new Error(
|
|
1332
1322
|
`Discord awareness ledger ${path} is ${version === undefined ? 'of no version' : `version ${JSON.stringify(version)}`}, ` +
|
|
1333
1323
|
'which this build can\'t read: it reads versions 2 and 3',
|
package/src/silent-heartbeat.ts
CHANGED
|
@@ -190,14 +190,17 @@ export interface RequestOnlyRowIndex {
|
|
|
190
190
|
/** Index the agent's own rows that follow request-only turns: the rows of its
|
|
191
191
|
* silent ticks, and the rows of each reply to a `[Continue]`. The two are
|
|
192
192
|
* independent, so a row can be both. Only stamped rows are keyed, so an
|
|
193
|
-
* agent's history walk costs a metadata read per row.
|
|
194
|
-
|
|
193
|
+
* agent's history walk costs a metadata read per row. `own` is the agent's
|
|
194
|
+
* name, or every participant that is its own voice, as a derived agent's
|
|
195
|
+
* inherited rows are. */
|
|
196
|
+
export function indexRequestOnlyRows(stored: readonly StoredRowLike[], own: string | ReadonlySet<string>): RequestOnlyRowIndex {
|
|
197
|
+
const ownNames = typeof own === 'string' ? new Set([own]) : own;
|
|
195
198
|
const ticks = new Map<string, { tick: string; text: string }>();
|
|
196
199
|
const prompts = new Map<string, RequestOnlyPrompt>();
|
|
197
200
|
for (const row of stored) {
|
|
198
|
-
if (row.participant
|
|
201
|
+
if (!ownNames.has(row.participant)) continue;
|
|
199
202
|
const stamp = silentHeartbeatOf(row.metadata);
|
|
200
|
-
const ownTick = stamp !== undefined && stamp.agentName ===
|
|
203
|
+
const ownTick = stamp !== undefined && stamp.agentName === row.participant;
|
|
201
204
|
const prompt = promptedByOf(row.metadata);
|
|
202
205
|
if (!ownTick && !prompt) continue;
|
|
203
206
|
const keys = identityKeys(row.content);
|
package/src/stored-injections.ts
CHANGED
|
@@ -84,6 +84,13 @@ export function isAnotherAgentsInjection(metadata: unknown, agentName: string):
|
|
|
84
84
|
return mark !== undefined && mark.agent !== agentName;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Whether a row is `agentName`'s own stored injection: its context, which
|
|
88
|
+
* its view keeps however the rest is read, as a shared fork's does, whose
|
|
89
|
+
* view is otherwise its source's (AgentFramework's deriveSharedContext). */
|
|
90
|
+
export function isOwnInjection(metadata: unknown, agentName: string): boolean {
|
|
91
|
+
return storedInjectionMarkOf(metadata)?.agent === agentName;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
/**
|
|
88
95
|
* Whether an injection is stored, rather than placed by `compile()` each
|
|
89
96
|
* time: an `afterUser` one, unless it opts out
|