@wowyuarm/dsh-agent-team 0.1.8 → 0.1.10
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 +4 -11
- package/README.zh.md +4 -11
- package/package.json +54 -44
- package/packages/agent-team/README.md +7 -5
- package/packages/agent-team/README.zh.md +7 -4
- package/packages/agent-team/lib/attachments.js +14 -4
- package/packages/agent-team/lib/context-management.js +382 -0
- package/packages/agent-team/lib/context-projection.js +578 -0
- package/packages/agent-team/lib/context-source.js +212 -0
- package/packages/agent-team/lib/index.js +1272 -76
- package/packages/agent-team/lib/ledger.js +333 -44
- package/packages/agent-team/lib/member-context.js +6 -2
- package/packages/agent-team/lib/member-runtime.js +159 -4
- package/packages/agent-team/lib/member-time-context.js +126 -0
- package/packages/agent-team/lib/pressure-policy.js +199 -0
- package/packages/agent-team/lib/progress-nudge.js +12 -4
- package/packages/agent-team/lib/session-remediation.js +481 -0
- package/packages/agent-team/lib/spec.js +21 -2
- package/packages/agent-team/lib/time-format.js +56 -0
- package/packages/agent-team/lib/typert.host.js +168 -54
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +59 -25
- package/packages/agent-team/lib/types/attachments.d.ts +1 -1
- package/packages/agent-team/lib/types/attachments.d.ts.map +1 -1
- package/packages/agent-team/lib/types/context-management.d.ts +147 -0
- package/packages/agent-team/lib/types/context-management.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-projection.d.ts +219 -0
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-source.d.ts +117 -0
- package/packages/agent-team/lib/types/context-source.d.ts.map +1 -0
- package/packages/agent-team/lib/types/index.d.ts +300 -2
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +119 -6
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts +30 -2
- package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-time-context.d.ts +83 -0
- package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts +106 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -0
- package/packages/agent-team/lib/types/progress-nudge.d.ts.map +1 -1
- package/packages/agent-team/lib/types/session-remediation.d.ts +149 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/time-format.d.ts +28 -0
- package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types/entities.d.ts +22 -1
- package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/operations.d.ts +38 -3
- package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts +58 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +27 -4
- package/packages/client-agent-team/lib/client.js +369 -352
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +1 -2
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +61 -41
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +2 -2
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +4 -4
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +2 -2
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +44 -16
- package/packages/client-agent-team/lib/types/client/locales.d.ts +0 -14
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +0 -14
- package/packages/client-agent-team/lib/types/client/slots.d.ts +6 -1
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/tool-agent-team/README.md +11 -6
- package/packages/tool-agent-team/README.zh.md +11 -6
- package/packages/tool-agent-team/lib/context-tools.js +179 -0
- package/packages/tool-agent-team/lib/index.js +369 -58
- package/packages/tool-agent-team/lib/types/context-tools.d.ts +16 -0
- package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -0
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/auto-compaction.js +0 -201
- package/packages/agent-team/lib/types/auto-compaction.d.ts +0 -47
- package/packages/agent-team/lib/types/auto-compaction.d.ts.map +0 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup remediation of legacy Member Session artifacts.
|
|
3
|
+
*
|
|
4
|
+
* Released dsh-agent-team 0.1.9 wrote its rollover handoff and checkpoint
|
|
5
|
+
* continuation into Member Session logs under bespoke message source kinds.
|
|
6
|
+
* dsh 0.1.5's session-format migration chain admits a closed source-kind
|
|
7
|
+
* vocabulary and refuses those artifacts fail-closed — the bytes are intact,
|
|
8
|
+
* but no reader (activation, session search, lineage timeline) can open them,
|
|
9
|
+
* which surfaces as Members going `unavailable`.
|
|
10
|
+
*
|
|
11
|
+
* This module restores readability without touching a byte of the refused
|
|
12
|
+
* artifact: it decodes the stored v0 log physically (frame-walking the
|
|
13
|
+
* concatenated zstd container), rewrites only this plugin's legacy source
|
|
14
|
+
* kinds into the admitted `plugin` shape the current writer already uses,
|
|
15
|
+
* re-validates the whole stream through the format catalog's own migration
|
|
16
|
+
* chain in memory, and publishes the migrated log as a sibling current-format
|
|
17
|
+
* artifact (`session.v3.jsonl.zstd`) — the storage layer's designated slot for
|
|
18
|
+
* a published current generation, atomically created and never overwritten.
|
|
19
|
+
* The v0 original remains the rollback path: delete the sibling.
|
|
20
|
+
*
|
|
21
|
+
* Scope is deliberately narrow: artifacts refused for any other reason
|
|
22
|
+
* (structural turn defects, foreign legacy fields) are left untouched with a
|
|
23
|
+
* diagnostic log — rewriting history a plugin did not author is out of
|
|
24
|
+
* bounds. Member availability for those rests on the activation recovery
|
|
25
|
+
* path's bounded fail-open, not on this migration.
|
|
26
|
+
*
|
|
27
|
+
* The walk runs once per plugin start, before any Member activation (no
|
|
28
|
+
* write lease exists yet) and records completion per Member in a separate
|
|
29
|
+
* durable domain — never in the `agent_team` ledger domain, whose version is
|
|
30
|
+
* the operation schema's compatibility contract, not a cache stamp.
|
|
31
|
+
* @module @wowyuarm/dsh-agent-team/session-remediation
|
|
32
|
+
*/
|
|
33
|
+
import { randomUUID } from 'node:crypto';
|
|
34
|
+
import { link, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
35
|
+
import { basename, dirname, join } from 'node:path';
|
|
36
|
+
import { constants as zstdConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib';
|
|
37
|
+
import { z } from 'zod';
|
|
38
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
|
|
39
|
+
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog';
|
|
40
|
+
import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence';
|
|
41
|
+
import { AGENT_TEAM_PLUGIN_ID, CHECKPOINT_SECTION_NAME, HANDOFF_CHECKPOINT, HANDOFF_EVENT_SEQ, HANDOFF_NEW_SESSION, HANDOFF_PREVIOUS_SESSION, HANDOFF_RELATED_FILES, HANDOFF_TRIGGER, } from "./context-source.js";
|
|
42
|
+
/** The rollover-handoff kind released 0.1.9 wrote; it is also handoff evidence in a log. */
|
|
43
|
+
export const LEGACY_HANDOFF_SOURCE_KIND = 'agent-team-context-handoff';
|
|
44
|
+
/** The two bespoke source kinds released 0.1.9 wrote; the migration's whole scope. */
|
|
45
|
+
const LEGACY_SOURCE_KINDS = new Set([LEGACY_HANDOFF_SOURCE_KIND, 'agent-team-context-continuation']);
|
|
46
|
+
/** The handoff kind alone, for handoff-presence questions. */
|
|
47
|
+
const LEGACY_HANDOFF_SOURCE_KINDS = new Set([LEGACY_HANDOFF_SOURCE_KIND]);
|
|
48
|
+
/** Sibling filename the storage layer reads in preference when present. */
|
|
49
|
+
const CURRENT_GENERATION_FILENAME = 'session.v3.jsonl.zstd';
|
|
50
|
+
/** Lineage walks stop at this depth; a chain this long is a defect, not history. */
|
|
51
|
+
const MAX_LINEAGE_DEPTH = 64;
|
|
52
|
+
/**
|
|
53
|
+
* Durable completion cache. A separate domain on purpose: the `agent_team`
|
|
54
|
+
* domain's version gates the ledger schema, and stamping migration bookkeeping
|
|
55
|
+
* there would turn a cache refresh into a ledger-format break. Records are
|
|
56
|
+
* disposable derived state — losing them only re-runs the (idempotent) walk.
|
|
57
|
+
*/
|
|
58
|
+
const sessionRemediationDomainSpec = defineDomain({
|
|
59
|
+
name: 'agent_team_remediation',
|
|
60
|
+
version: 1,
|
|
61
|
+
tables: {
|
|
62
|
+
lineages: domainTable(z.object({
|
|
63
|
+
memberId: z.string().min(1),
|
|
64
|
+
formatVersion: z.number().int().nonnegative(),
|
|
65
|
+
currentSessionId: z.string().min(1),
|
|
66
|
+
verifiedAt: z.number().int().nonnegative(),
|
|
67
|
+
}).strict()),
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
/**
|
|
71
|
+
* Rewrite one legacy source onto the admitted shape the current writer uses.
|
|
72
|
+
* The original `sections` array is preserved verbatim (it carries the
|
|
73
|
+
* model-authored handoff prose); envelope fields ride additional named
|
|
74
|
+
* sections exactly as `handoffSections` composes them, added only when the
|
|
75
|
+
* field is present and no section of that name exists.
|
|
76
|
+
*/
|
|
77
|
+
function admitLegacySource(source) {
|
|
78
|
+
if (source.kind === 'agent-team-context-handoff') {
|
|
79
|
+
const sections = Array.isArray(source.sections)
|
|
80
|
+
? source.sections.filter((section) => section !== null && typeof section === 'object')
|
|
81
|
+
: [];
|
|
82
|
+
const envelope = [
|
|
83
|
+
[HANDOFF_PREVIOUS_SESSION, source.previousSessionId],
|
|
84
|
+
[HANDOFF_NEW_SESSION, source.newSessionId],
|
|
85
|
+
[HANDOFF_TRIGGER, source.trigger],
|
|
86
|
+
[HANDOFF_EVENT_SEQ, source.handoffEventSeq],
|
|
87
|
+
[HANDOFF_CHECKPOINT, source.checkpointRef],
|
|
88
|
+
[HANDOFF_RELATED_FILES, Array.isArray(source.relatedFiles)
|
|
89
|
+
? source.relatedFiles.filter((file) => typeof file === 'string').join(', ')
|
|
90
|
+
: undefined],
|
|
91
|
+
];
|
|
92
|
+
for (const [name, value] of envelope) {
|
|
93
|
+
if (value === undefined || value === null || value === '')
|
|
94
|
+
continue;
|
|
95
|
+
if (sections.some(section => section.name === name))
|
|
96
|
+
continue;
|
|
97
|
+
sections.push({ name, text: String(value) });
|
|
98
|
+
}
|
|
99
|
+
return { kind: 'plugin', plugin: AGENT_TEAM_PLUGIN_ID, form: 'snapshot', sections };
|
|
100
|
+
}
|
|
101
|
+
// A continuation carried its checkpoint ref at the top level; the admitted
|
|
102
|
+
// shape reads it back from a single-name section, same as the writer.
|
|
103
|
+
return {
|
|
104
|
+
kind: 'plugin',
|
|
105
|
+
plugin: AGENT_TEAM_PLUGIN_ID,
|
|
106
|
+
form: 'snapshot',
|
|
107
|
+
sections: [{ name: CHECKPOINT_SECTION_NAME, text: String(source.checkpointRef ?? '') }],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Deep-rewrite every legacy Team source in one physical row tree. The kinds
|
|
112
|
+
* are globally unique to this plugin, so a structural match anywhere in the
|
|
113
|
+
* row (message sources ride several payload positions) is exactly the set to
|
|
114
|
+
* rewrite; everything else passes through untouched.
|
|
115
|
+
*/
|
|
116
|
+
export function admitLegacyRow(value) {
|
|
117
|
+
if (value === null || typeof value !== 'object')
|
|
118
|
+
return value;
|
|
119
|
+
if (Array.isArray(value))
|
|
120
|
+
return value.map(admitLegacyRow);
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [key, item] of Object.entries(value))
|
|
123
|
+
out[key] = admitLegacyRow(item);
|
|
124
|
+
if (typeof out.kind === 'string' && LEGACY_SOURCE_KINDS.has(out.kind))
|
|
125
|
+
return admitLegacySource(out);
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
/** Whether one value tree still contains a legacy Team source kind. */
|
|
129
|
+
export function containsLegacySource(value) {
|
|
130
|
+
return containsLegacySourceKind(value, LEGACY_SOURCE_KINDS);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether one value tree carries the retired rollover-handoff kind. A
|
|
134
|
+
* continuation is deliberately excluded: it never carried a handoff, so it is
|
|
135
|
+
* not evidence that one already arrived.
|
|
136
|
+
*/
|
|
137
|
+
export function carriesLegacyHandoffSource(value) {
|
|
138
|
+
return containsLegacySourceKind(value, LEGACY_HANDOFF_SOURCE_KINDS);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether a generation's log already holds a rollover handoff, in either
|
|
142
|
+
* shape. The admitted shape surfaces as a projection boundary; a generation
|
|
143
|
+
* rescued from the retired kinds carries the very same handoff as a source the
|
|
144
|
+
* projection does not classify, so recovery that keys on the boundary alone
|
|
145
|
+
* would deliver that handoff a second time.
|
|
146
|
+
*/
|
|
147
|
+
export function handoffAlreadyInLog(boundaries, ownEvents) {
|
|
148
|
+
if (boundaries.some(boundary => boundary.source === 'handoff'))
|
|
149
|
+
return true;
|
|
150
|
+
return ownEvents.some(carriesLegacyHandoffSource);
|
|
151
|
+
}
|
|
152
|
+
function containsLegacySourceKind(value, kinds) {
|
|
153
|
+
if (value === null || typeof value !== 'object')
|
|
154
|
+
return false;
|
|
155
|
+
if (Array.isArray(value))
|
|
156
|
+
return value.some(item => containsLegacySourceKind(item, kinds));
|
|
157
|
+
const kind = value.kind;
|
|
158
|
+
if (typeof kind === 'string' && kinds.has(kind))
|
|
159
|
+
return true;
|
|
160
|
+
return Object.values(value).some(item => containsLegacySourceKind(item, kinds));
|
|
161
|
+
}
|
|
162
|
+
const ZSTD_MAGIC = 0xfd2fb528;
|
|
163
|
+
const SKIPPABLE_FROM = 0x184d2a50;
|
|
164
|
+
const SKIPPABLE_TO = 0x184d2a5f;
|
|
165
|
+
/**
|
|
166
|
+
* Byte ranges of every complete zstd frame in a concatenated container, plus
|
|
167
|
+
* the offset of a torn final frame. Node's zstd bindings decode only the
|
|
168
|
+
* first frame of their input, so multi-frame containers must be walked
|
|
169
|
+
* structurally; scanning for the magic alone is unsound because the magic
|
|
170
|
+
* sequence can occur inside compressed payloads.
|
|
171
|
+
*/
|
|
172
|
+
export function scanZstdFrames(buffer) {
|
|
173
|
+
const frames = [];
|
|
174
|
+
let at = 0;
|
|
175
|
+
let tornStart;
|
|
176
|
+
while (at < buffer.length) {
|
|
177
|
+
if (at + 4 > buffer.length) {
|
|
178
|
+
tornStart = at;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const magic = buffer.readUInt32LE(at);
|
|
182
|
+
if (magic >= SKIPPABLE_FROM && magic <= SKIPPABLE_TO) {
|
|
183
|
+
if (at + 8 > buffer.length) {
|
|
184
|
+
tornStart = at;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
const size = buffer.readUInt32LE(at + 4);
|
|
188
|
+
if (at + 8 + size > buffer.length) {
|
|
189
|
+
tornStart = at;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
frames.push({ start: at, end: at + 8 + size, skippable: true });
|
|
193
|
+
at += 8 + size;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (magic !== ZSTD_MAGIC)
|
|
197
|
+
throw new Error(`not a zstd frame at byte ${at}`);
|
|
198
|
+
if (at + 5 > buffer.length) {
|
|
199
|
+
tornStart = at;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
const descriptor = buffer.readUInt8(at + 4);
|
|
203
|
+
const contentSizeFlag = descriptor >> 6;
|
|
204
|
+
const singleSegment = (descriptor >> 5) & 1;
|
|
205
|
+
const hasChecksum = (descriptor >> 2) & 1;
|
|
206
|
+
const dictionaryFlag = descriptor & 3;
|
|
207
|
+
let cursor = at + 5;
|
|
208
|
+
if (singleSegment === 0)
|
|
209
|
+
cursor += 1;
|
|
210
|
+
const dictionaryLengths = [0, 1, 2, 4];
|
|
211
|
+
const contentSizeLengths = [0, 2, 4, 8];
|
|
212
|
+
cursor += dictionaryLengths[dictionaryFlag] ?? 0;
|
|
213
|
+
cursor += contentSizeFlag === 0 ? (singleSegment === 1 ? 1 : 0) : (contentSizeLengths[contentSizeFlag] ?? 0);
|
|
214
|
+
for (;;) {
|
|
215
|
+
if (cursor + 3 > buffer.length)
|
|
216
|
+
throw new Error(`torn zstd block header at byte ${cursor}`);
|
|
217
|
+
const header = buffer.readUInt8(cursor) | (buffer.readUInt8(cursor + 1) << 8) | (buffer.readUInt8(cursor + 2) << 16);
|
|
218
|
+
const last = header & 1;
|
|
219
|
+
const blockType = (header >> 1) & 3;
|
|
220
|
+
const size = header >> 3;
|
|
221
|
+
if (blockType === 3)
|
|
222
|
+
throw new Error(`reserved zstd block type at byte ${cursor}`);
|
|
223
|
+
cursor += 3 + (blockType === 1 ? 1 : size);
|
|
224
|
+
if (last === 1)
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (hasChecksum === 1)
|
|
228
|
+
cursor += 4;
|
|
229
|
+
if (cursor > buffer.length)
|
|
230
|
+
throw new Error(`torn zstd frame at byte ${at}`);
|
|
231
|
+
frames.push({ start: at, end: cursor, skippable: false });
|
|
232
|
+
at = cursor;
|
|
233
|
+
}
|
|
234
|
+
return { frames, tornStart };
|
|
235
|
+
}
|
|
236
|
+
/** Decode one stored zstd container to its physical JSONL rows. */
|
|
237
|
+
export function readPhysicalRows(buffer) {
|
|
238
|
+
const { frames } = scanZstdFrames(buffer);
|
|
239
|
+
const chunks = frames.map(frame => frame.skippable ? Buffer.alloc(0) : zstdDecompressSync(buffer.subarray(frame.start, frame.end)));
|
|
240
|
+
const lines = Buffer.concat(chunks).toString('utf8').split('\n').filter(line => line.length > 0);
|
|
241
|
+
return lines.map(line => JSON.parse(line));
|
|
242
|
+
}
|
|
243
|
+
/** Encode the migrated artifact as one checksummed zstd frame per JSONL row. */
|
|
244
|
+
function encodeCurrentArtifact(artifact) {
|
|
245
|
+
const checksum = { params: { [zstdConstants.ZSTD_c_checksumFlag]: 1 } };
|
|
246
|
+
const lines = [
|
|
247
|
+
JSON.stringify(sessionFormatCatalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount)),
|
|
248
|
+
...artifact.events.map(event => JSON.stringify(sessionFormatCatalog.encodeCurrentEvent(event))),
|
|
249
|
+
];
|
|
250
|
+
return Buffer.concat(lines.map(line => zstdCompressSync(Buffer.from(line + '\n'), checksum)));
|
|
251
|
+
}
|
|
252
|
+
/** Run the format catalog's own migration chain over transformed rows; throws on any residual refusal. */
|
|
253
|
+
function migrateForProof(rows) {
|
|
254
|
+
const [header, ...events] = rows;
|
|
255
|
+
const handle = sessionFormatCatalog.createRestore(header, { recovery: 'recoverable', validation: 'current' });
|
|
256
|
+
for (const row of events)
|
|
257
|
+
handle.decodeRow(row);
|
|
258
|
+
return handle.finish();
|
|
259
|
+
}
|
|
260
|
+
/** The JSONL artifact path a format refusal reports, when it reports one. */
|
|
261
|
+
function artifactPath(error) {
|
|
262
|
+
return error.location?.kind === 'jsonl' ? error.location.path : undefined;
|
|
263
|
+
}
|
|
264
|
+
/** The recorded parent Session of one physical header row. */
|
|
265
|
+
function physicalParentSession(rows) {
|
|
266
|
+
const parent = rows[0]?.parentSession;
|
|
267
|
+
return typeof parent === 'string' ? parent : undefined;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* The startup remediation pass. Never throws to its caller: a failed or
|
|
271
|
+
* partial walk logs and leaves the completion cache unrecorded, so the next
|
|
272
|
+
* start retries exactly the Members that need it.
|
|
273
|
+
*/
|
|
274
|
+
export class SessionRemediation {
|
|
275
|
+
ctx;
|
|
276
|
+
persistence;
|
|
277
|
+
table;
|
|
278
|
+
constructor(ctx, persistence, domain) {
|
|
279
|
+
this.ctx = ctx;
|
|
280
|
+
this.persistence = persistence;
|
|
281
|
+
this.table = domain?.table('lineages');
|
|
282
|
+
}
|
|
283
|
+
/** Open the completion-cache domain; a failure only costs re-walking. */
|
|
284
|
+
static async open(ctx) {
|
|
285
|
+
try {
|
|
286
|
+
return await ctx.storageDomain.open(sessionRemediationDomainSpec);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
ctx.logger.warn(`agent-team: session remediation cache unavailable (walks will re-run): ${error instanceof Error ? error.message : String(error)}`);
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Remediate every enabled Member's lineage once, before activation. A
|
|
295
|
+
* Member is skipped when its cache record still matches the current format
|
|
296
|
+
* version and Session binding — a rollover or a dsh format change is what
|
|
297
|
+
* re-opens the walk.
|
|
298
|
+
*/
|
|
299
|
+
async remediateEnabledMembers(members) {
|
|
300
|
+
let repaired = 0;
|
|
301
|
+
let untouched = 0;
|
|
302
|
+
let walked = 0;
|
|
303
|
+
for (const member of members) {
|
|
304
|
+
if (member.state !== 'enabled')
|
|
305
|
+
continue;
|
|
306
|
+
if (this.cacheStillValid(member))
|
|
307
|
+
continue;
|
|
308
|
+
walked += 1;
|
|
309
|
+
let repairedCount = 0;
|
|
310
|
+
let untouchedCount = 0;
|
|
311
|
+
try {
|
|
312
|
+
const summary = await this.remediateLineage(member);
|
|
313
|
+
repairedCount = summary.repaired;
|
|
314
|
+
untouchedCount = summary.untouched;
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
// Unexpected per-Member failure: log and leave the cache unrecorded.
|
|
318
|
+
this.ctx.logger.warn(`agent-team: legacy Session remediation for member '${member.handle}' did not complete: ${error instanceof Error ? error.message : String(error)}`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
repaired += repairedCount;
|
|
322
|
+
untouched += untouchedCount;
|
|
323
|
+
await this.recordCompletion(member);
|
|
324
|
+
}
|
|
325
|
+
if (walked > 0) {
|
|
326
|
+
this.ctx.logger.info(`agent-team: legacy Session remediation walked ${walked} member lineage(s): ${repaired} artifact(s) repaired, ${untouched} left untouched`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** Whether the cached walk still covers this Member under the current format. */
|
|
330
|
+
cacheStillValid(member) {
|
|
331
|
+
const record = this.table?.get(member.memberId);
|
|
332
|
+
return record !== undefined
|
|
333
|
+
&& record.formatVersion === sessionFormatCatalog.currentVersion
|
|
334
|
+
&& record.currentSessionId === member.sessionId;
|
|
335
|
+
}
|
|
336
|
+
async recordCompletion(member) {
|
|
337
|
+
if (this.table === undefined)
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
await this.table.put(member.memberId, {
|
|
341
|
+
memberId: member.memberId,
|
|
342
|
+
formatVersion: sessionFormatCatalog.currentVersion,
|
|
343
|
+
currentSessionId: member.sessionId,
|
|
344
|
+
verifiedAt: Date.now(),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
this.ctx.logger.warn(`agent-team: could not record session remediation completion for member '${member.handle}' (the walk will re-run): ${error instanceof Error ? error.message : String(error)}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Walk one Member's lineage from its bound Session up through recorded
|
|
353
|
+
* parent Sessions, remediating refused artifacts in place. Readable
|
|
354
|
+
* artifacts still carrying legacy kinds inside a current-format generation
|
|
355
|
+
* (an artifact of the one-off 2026-09-10 manual rescue) are counted and
|
|
356
|
+
* logged but not rewritten: they are readable, and rewriting a generation
|
|
357
|
+
* the storage layer may still append to is a write-path risk this pass
|
|
358
|
+
* does not take.
|
|
359
|
+
*/
|
|
360
|
+
async remediateLineage(member) {
|
|
361
|
+
let repaired = 0;
|
|
362
|
+
let untouched = 0;
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
let sessionId = member.sessionId;
|
|
365
|
+
for (let depth = 0; sessionId !== undefined && !seen.has(sessionId) && depth < MAX_LINEAGE_DEPTH; depth += 1) {
|
|
366
|
+
seen.add(sessionId);
|
|
367
|
+
const outcome = await this.remediateOne(sessionId, member);
|
|
368
|
+
if (outcome.status === 'repaired')
|
|
369
|
+
repaired += 1;
|
|
370
|
+
else if (outcome.status === 'left-untouched')
|
|
371
|
+
untouched += 1;
|
|
372
|
+
sessionId = outcome.parentSession === undefined ? undefined : outcome.parentSession;
|
|
373
|
+
}
|
|
374
|
+
return { repaired, untouched };
|
|
375
|
+
}
|
|
376
|
+
/** Remediate one lineage Session: verify readability, and repair when the refusal is this plugin's legacy source shape. */
|
|
377
|
+
async remediateOne(sessionId, member) {
|
|
378
|
+
try {
|
|
379
|
+
const handle = await this.persistence.open(sessionId, 'read');
|
|
380
|
+
try {
|
|
381
|
+
const { events } = await handle.read();
|
|
382
|
+
if (events.some(event => containsLegacySource(event))) {
|
|
383
|
+
this.ctx.logger.info(`agent-team: Session '${sessionId}' of member '${member.handle}' reads fine but still carries legacy envelope kinds; its history metadata stays degraded until the next rollover`);
|
|
384
|
+
}
|
|
385
|
+
return { status: 'already-readable', parentSession: handle.header.parentSession };
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
await handle.close();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
if (!(error instanceof SessionFormatUnsupportedError)) {
|
|
393
|
+
this.ctx.logger.warn(`agent-team: Session '${sessionId}' of member '${member.handle}' is unreadable for a non-format reason: ${error instanceof Error ? error.message : String(error)}`);
|
|
394
|
+
return { status: 'left-untouched', parentSession: undefined, reason: 'unreadable for a non-format reason' };
|
|
395
|
+
}
|
|
396
|
+
const path = artifactPath(error);
|
|
397
|
+
if (path === undefined) {
|
|
398
|
+
this.ctx.logger.warn(`agent-team: refused Session '${sessionId}' of member '${member.handle}' did not report a JSONL artifact path: ${error.message}`);
|
|
399
|
+
return { status: 'left-untouched', parentSession: undefined, reason: 'no artifact path reported' };
|
|
400
|
+
}
|
|
401
|
+
return await this.repairArtifact(sessionId, path, member, error);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** Transform, prove, and publish the sibling current-format artifact for one refused Session. */
|
|
405
|
+
async repairArtifact(sessionId, path, member, refusal) {
|
|
406
|
+
let rows;
|
|
407
|
+
try {
|
|
408
|
+
rows = readPhysicalRows(await readFile(path));
|
|
409
|
+
}
|
|
410
|
+
catch (readError) {
|
|
411
|
+
this.ctx.logger.warn(`agent-team: could not decode the stored artifact of Session '${sessionId}' (${basename(path)}): ${readError instanceof Error ? readError.message : String(readError)}`);
|
|
412
|
+
return { status: 'left-untouched', parentSession: undefined, reason: 'artifact undecodable' };
|
|
413
|
+
}
|
|
414
|
+
const parentSession = physicalParentSession(rows);
|
|
415
|
+
if (!rows.some(row => containsLegacySource(row))) {
|
|
416
|
+
this.ctx.logger.warn(`agent-team: Session '${sessionId}' of member '${member.handle}' is refused for a reason outside this plugin's writes: ${refusal.message.split('\n')[0]}; leaving it untouched`);
|
|
417
|
+
return { status: 'left-untouched', parentSession, reason: 'refusal outside this plugin writes' };
|
|
418
|
+
}
|
|
419
|
+
const admitted = rows.map(admitLegacyRow);
|
|
420
|
+
let artifact;
|
|
421
|
+
try {
|
|
422
|
+
artifact = migrateForProof(admitted.map(row => structuredClone(row)));
|
|
423
|
+
}
|
|
424
|
+
catch (proofError) {
|
|
425
|
+
this.ctx.logger.warn(`agent-team: Session '${sessionId}' of member '${member.handle}' still refuses after the legacy source rewrite (${proofError instanceof Error ? proofError.message : String(proofError)}); leaving it untouched`);
|
|
426
|
+
return { status: 'left-untouched', parentSession, reason: 'residual refusal after rewrite' };
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const published = await this.publishSibling(path, encodeCurrentArtifact(artifact));
|
|
430
|
+
if (published === 'already-present') {
|
|
431
|
+
this.ctx.logger.warn(`agent-team: Session '${sessionId}' already has a current-format sibling; leaving both untouched`);
|
|
432
|
+
return { status: 'left-untouched', parentSession, reason: 'sibling already present' };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
catch (publishError) {
|
|
436
|
+
this.ctx.logger.warn(`agent-team: could not publish the remediated sibling for Session '${sessionId}' (${basename(path)}): ${publishError instanceof Error ? publishError.message : String(publishError)}`);
|
|
437
|
+
return { status: 'left-untouched', parentSession, reason: 'publish failed' };
|
|
438
|
+
}
|
|
439
|
+
// Success is proven by the storage layer itself, not by our own writer.
|
|
440
|
+
try {
|
|
441
|
+
const handle = await this.persistence.open(sessionId, 'read');
|
|
442
|
+
try {
|
|
443
|
+
await handle.read();
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
await handle.close();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch (verifyError) {
|
|
450
|
+
this.ctx.logger.warn(`agent-team: the remediated sibling for Session '${sessionId}' was published but still refuses: ${verifyError instanceof Error ? verifyError.message : String(verifyError)}`);
|
|
451
|
+
return { status: 'left-untouched', parentSession, reason: 'sibling refused after publish' };
|
|
452
|
+
}
|
|
453
|
+
this.ctx.logger.info(`agent-team: repaired legacy source kinds in Session '${sessionId}' of member '${member.handle}' by publishing a current-format sibling; the original artifact is untouched`);
|
|
454
|
+
return { status: 'repaired', parentSession };
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Atomically place the sibling file: stage in the same directory, hardlink
|
|
458
|
+
* into place (an existing target refuses, never overwrites), remove the
|
|
459
|
+
* stage. The link dance matches the storage layer's own exclusive publish.
|
|
460
|
+
*/
|
|
461
|
+
async publishSibling(sourcePath, bytes) {
|
|
462
|
+
const directory = dirname(sourcePath);
|
|
463
|
+
const target = join(directory, CURRENT_GENERATION_FILENAME);
|
|
464
|
+
if (await stat(target).then(() => true, () => false))
|
|
465
|
+
return 'already-present';
|
|
466
|
+
const staged = join(directory, `.remediation-${randomUUID()}.tmp`);
|
|
467
|
+
await writeFile(staged, bytes, { flag: 'wx' });
|
|
468
|
+
try {
|
|
469
|
+
await link(staged, target);
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
if (await stat(target).then(() => true, () => false))
|
|
473
|
+
return 'already-present';
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
finally {
|
|
477
|
+
await rm(staged, { force: true });
|
|
478
|
+
}
|
|
479
|
+
return 'published';
|
|
480
|
+
}
|
|
481
|
+
}
|
|
@@ -5,6 +5,8 @@ const requestIdSchema = z.string().min(1).transform(value => value);
|
|
|
5
5
|
const memberIdSchema = z.string().regex(/^member:[^:]+$/).transform(value => value);
|
|
6
6
|
const workspaceIdSchema = z.string().min(1).transform(value => value);
|
|
7
7
|
const sessionIdSchema = z.string().min(1).transform(value => value);
|
|
8
|
+
const sessionSeqSchema = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).transform(value => value);
|
|
9
|
+
const contextCheckpointRefSchema = z.string().regex(/^(context-checkpoint-[0-9a-f]{64}|team-boundary-[0-9a-f]{64})$/).transform(value => value);
|
|
8
10
|
const channelRefSchema = z.string().regex(/^channel:[^:]+$/).transform(value => value);
|
|
9
11
|
const messageRefSchema = z.string().regex(/^message:[^:]+$/).transform(value => value);
|
|
10
12
|
const taskRefSchema = z.string().regex(/^task:[^:]+$/).transform(value => value);
|
|
@@ -181,6 +183,7 @@ const taskActivitySchema = z.object({
|
|
|
181
183
|
kind: z.union([z.literal('promote'), z.literal('accept'), z.literal('close'), z.literal('reopen')]),
|
|
182
184
|
releasedClaimRefs: z.array(claimRefSchema).min(1).optional(),
|
|
183
185
|
completedClaimRefs: z.array(claimRefSchema).min(1).optional(),
|
|
186
|
+
acceptedClaimRefs: z.array(claimRefSchema).optional(),
|
|
184
187
|
}).strict();
|
|
185
188
|
const claimsReleasedActivitySchema = z.object({
|
|
186
189
|
...activityBase,
|
|
@@ -190,8 +193,9 @@ const claimsReleasedActivitySchema = z.object({
|
|
|
190
193
|
const activitySchema = z.discriminatedUnion('kind', [claimActivitySchema, taskActivitySchema, claimsReleasedActivitySchema]);
|
|
191
194
|
const threadFactSchema = z.discriminatedUnion('kind', [
|
|
192
195
|
z.object({ kind: z.literal('message'), sequence: z.number().int().positive(), message: messageSchema,
|
|
193
|
-
mentions: z.array(memberIdSchema) }).strict(),
|
|
194
|
-
z.object({ kind: z.literal('activity'), sequence: z.number().int().positive(), activity: activitySchema
|
|
196
|
+
mentions: z.array(memberIdSchema), occurredAt: z.string().datetime().optional() }).strict(),
|
|
197
|
+
z.object({ kind: z.literal('activity'), sequence: z.number().int().positive(), activity: activitySchema,
|
|
198
|
+
occurredAt: z.string().datetime().optional() }).strict(),
|
|
195
199
|
]);
|
|
196
200
|
const readFactSchema = z.object({
|
|
197
201
|
fact: threadFactSchema,
|
|
@@ -279,6 +283,21 @@ const storedAgentTeamOperationSchema = z.discriminatedUnion('kind', [
|
|
|
279
283
|
kind: z.literal('team/member-session-renewed'),
|
|
280
284
|
data: z.object({ member: memberSchema, previousSessionId: sessionIdSchema }).strict(),
|
|
281
285
|
}).strict(),
|
|
286
|
+
z.object({
|
|
287
|
+
...operationBase,
|
|
288
|
+
previousOperationId: operationIdSchema.nullable(),
|
|
289
|
+
kind: z.literal('team/member-session-rolled-over'),
|
|
290
|
+
data: z.object({
|
|
291
|
+
member: memberSchema,
|
|
292
|
+
previousSessionId: sessionIdSchema,
|
|
293
|
+
newSessionId: sessionIdSchema,
|
|
294
|
+
sourceSessionId: sessionIdSchema.optional(),
|
|
295
|
+
sourceThroughSeq: sessionSeqSchema.optional(),
|
|
296
|
+
handoffEventSeq: sessionSeqSchema,
|
|
297
|
+
checkpointRef: contextCheckpointRefSchema.optional(),
|
|
298
|
+
trigger: z.enum(['model', 'pressure']),
|
|
299
|
+
}).strict(),
|
|
300
|
+
}).strict(),
|
|
282
301
|
z.object({
|
|
283
302
|
...operationBase,
|
|
284
303
|
previousOperationId: operationIdSchema.nullable(),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one agent-facing Team timestamp formatter.
|
|
3
|
+
*
|
|
4
|
+
* Ledger storage keeps UTC ISO instants (`occurredAt` on every operation);
|
|
5
|
+
* rendering converts them into the fixed Team coordination zone UTC+8 with
|
|
6
|
+
* an explicit offset (`2026-09-08T17:00:00+08:00`). The conversion is a
|
|
7
|
+
* fixed offset with no daylight-saving component, so the same stored instant
|
|
8
|
+
* renders byte-identically on every reread path — the context-cache
|
|
9
|
+
* invariant. The Web Client keeps its own browser-local rendering and never
|
|
10
|
+
* passes through this module; a future configuration layer may make the
|
|
11
|
+
* zone configurable, but the render must stay a single deterministic
|
|
12
|
+
* formatter per stored instant.
|
|
13
|
+
* @module @wowyuarm/dsh-agent-team/time-format
|
|
14
|
+
*/
|
|
15
|
+
/** The fixed Team coordination offset from UTC, in minutes. */
|
|
16
|
+
const TEAM_ZONE_OFFSET_MINUTES = 8 * 60;
|
|
17
|
+
/** Rendered offset text, e.g. `+08:00`. */
|
|
18
|
+
const OFFSET_TEXT = '+08:00';
|
|
19
|
+
/**
|
|
20
|
+
* Format one stored UTC ISO instant as the agent-facing Team timestamp.
|
|
21
|
+
* @param occurredAt - stored UTC ISO 8601 instant (ledger `occurredAt`).
|
|
22
|
+
* @returns the fixed-offset UTC+8 rendering, or the input unchanged when it
|
|
23
|
+
* cannot be parsed (never a fabricated time).
|
|
24
|
+
*/
|
|
25
|
+
export function formatTeamTimestamp(occurredAt) {
|
|
26
|
+
const instant = Date.parse(occurredAt);
|
|
27
|
+
if (!Number.isFinite(instant))
|
|
28
|
+
return occurredAt;
|
|
29
|
+
const shifted = new Date(instant + TEAM_ZONE_OFFSET_MINUTES * 60_000);
|
|
30
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
31
|
+
return `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`
|
|
32
|
+
+ `T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}${OFFSET_TEXT}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Format a non-negative elapsed millisecond count as compact whole-second
|
|
36
|
+
* units (`2d 3h 12m 8s`), mirroring the shipped harness time-context
|
|
37
|
+
* vocabulary so both clock families read the same way.
|
|
38
|
+
*/
|
|
39
|
+
export function formatTeamDuration(elapsedMs) {
|
|
40
|
+
let seconds = Math.floor(Math.max(0, elapsedMs) / 1000);
|
|
41
|
+
const days = Math.floor(seconds / 86_400);
|
|
42
|
+
seconds %= 86_400;
|
|
43
|
+
const hours = Math.floor(seconds / 3600);
|
|
44
|
+
seconds %= 3600;
|
|
45
|
+
const minutes = Math.floor(seconds / 60);
|
|
46
|
+
seconds %= 60;
|
|
47
|
+
const parts = [];
|
|
48
|
+
if (days > 0)
|
|
49
|
+
parts.push(`${days}d`);
|
|
50
|
+
if (hours > 0)
|
|
51
|
+
parts.push(`${hours}h`);
|
|
52
|
+
if (minutes > 0)
|
|
53
|
+
parts.push(`${minutes}m`);
|
|
54
|
+
parts.push(`${seconds}s`);
|
|
55
|
+
return parts.join(' ');
|
|
56
|
+
}
|