dsh-ptc-plus 0.5.1 → 0.5.2
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.en.md +7 -3
- package/README.md +7 -3
- package/client.cjs +1467 -84
- package/compiler-core.cjs +132 -132
- package/compiler-core.cjs.map +3 -3
- package/docs/adr/0005-temporary-rejected-cell-edit-transport.md +2 -0
- package/docs/adr/0027-run-the-session-kernel-in-a-killable-helper-process.md +11 -1
- package/docs/adr/0032-own-session-log-facts-in-a-host-only-projection.md +31 -1
- package/docs/adr/0045-customize-model-visible-prompt-text.md +38 -0
- package/docs/adr/0046-gate-the-commit-with-the-semantic-evidence-tier.md +35 -0
- package/docs/adr/0047-store-journal-argument-digests-in-the-projection.md +88 -0
- package/docs/architecture.md +2 -2
- package/docs/client-ui.md +5 -2
- package/docs/durability-design.md +7 -1
- package/docs/releases/v0.5.2.md +79 -0
- package/docs/runtime-reference.md +36 -2
- package/docs/unmeasured-cell-recovery-repair-proposal.md +387 -0
- package/docs/verification.md +6 -2
- package/index.js +36 -15
- package/internal/bounded-literal.js +85 -0
- package/internal/capability-environment.js +162 -10
- package/internal/capability-plan.js +11 -8
- package/internal/cell-error.js +13 -0
- package/internal/config-spec.js +113 -2
- package/internal/direct-surface-owner.js +14 -10
- package/internal/edit-transport-owner.js +79 -14
- package/internal/isolated-worker.js +134 -17
- package/internal/kernel-worker.js +167 -3
- package/internal/legacy-cell-analysis.js +14 -2
- package/internal/legacy-repl-convenience.js +16 -3
- package/internal/projection-retention.js +238 -0
- package/internal/prompt-text.js +858 -0
- package/internal/recovery-tips.js +50 -11
- package/internal/rejected-cell-editor.js +3 -2
- package/internal/runtime-bridge-owner.js +1 -1
- package/internal/runtime-config.js +2 -0
- package/internal/runtime-contexts.js +14 -5
- package/internal/runtime-messages.js +43 -15
- package/internal/session-cell-executor.js +132 -11
- package/internal/session-journal-recovery.js +1009 -99
- package/internal/session-journal-schema.js +7 -5
- package/internal/session-journal.js +177 -14
- package/internal/session-log-view.js +111 -24
- package/internal/session-runtime.js +674 -33
- package/internal/session-state.js +27 -0
- package/internal/stateful-root-compiler.js +11 -0
- package/internal/stateful-root-runtime.js +17 -0
- package/internal/user-binding-console.js +7 -0
- package/internal/user-bindings-owner.js +3 -0
- package/internal/user-bindings.js +2 -2
- package/internal/validated-parse-repair.js +6 -1
- package/internal/worker-client.js +27 -4
- package/package.json +3 -1
- package/src/client-activity.js +24 -4
- package/src/client-capabilities-view.js +459 -24
- package/src/client-copy.js +163 -2
- package/src/client-repl-view.js +6 -1
- package/src/client-styles.js +64 -2
package/internal/config-spec.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CAPABILITY_POLICY_DEFAULT } from './capability-policy.js'
|
|
2
|
+
import { PROMPT_TEXT_DEFAULT } from './prompt-text.js'
|
|
2
3
|
|
|
3
4
|
/** Configuration fields shared by the Host schema and settings card. */
|
|
4
5
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
@@ -215,6 +216,68 @@ export const CONFIG_FIELDS = Object.freeze([
|
|
|
215
216
|
description: 'worker 重启后从 session journal 重建可恢复的 REPL 状态。',
|
|
216
217
|
descriptionEn: 'Rebuilds recoverable REPL state from the session journal after a worker restart.',
|
|
217
218
|
},
|
|
219
|
+
{
|
|
220
|
+
key: 'retentionPayloadRuns',
|
|
221
|
+
type: 'integer',
|
|
222
|
+
default: 8,
|
|
223
|
+
min: 8,
|
|
224
|
+
max: 512,
|
|
225
|
+
label: '保留最近单元格数',
|
|
226
|
+
labelEn: 'Retained recent cells',
|
|
227
|
+
description: '检查点保留最近多少个单元格的参数原文。调大能直接编辑更久以前的计算,但检查点更大;更早的单元格需要从会话日志重新折叠才能编辑或重新发布。',
|
|
228
|
+
descriptionEn: 'How many recent cells keep their argument text in the checkpoint. A larger window keeps more history editable without a log read but grows the checkpoint; older cells must be refolded from the session log to be edited or republished.',
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
key: 'retentionPayloadBytes',
|
|
232
|
+
type: 'integer',
|
|
233
|
+
default: 8388608,
|
|
234
|
+
min: 1048576,
|
|
235
|
+
max: 16777216,
|
|
236
|
+
label: '保留载荷预算 (UTF-8 bytes)',
|
|
237
|
+
labelEn: 'Retained payload budget (UTF-8 bytes)',
|
|
238
|
+
description: '保留单元格参数原文的累计上限。达到上限后最旧的原文被移除,检查点体积因此与会话写入总量无关;单个超过预算的单元格也会被移除并从日志重折。检查点体积约为本预算的两倍,上限 16 MiB 对应约 32 MB 的最坏情况。',
|
|
239
|
+
descriptionEn: 'Cumulative budget for the retained argument text. Past it the oldest payload is removed, which keeps the checkpoint independent of how much the session wrote; a single cell larger than the budget is removed as well and refolded from the log. A checkpoint is roughly twice this budget, so the 16 MiB maximum bounds the worst case near 32 MB.',
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
key: 'retentionClusters',
|
|
243
|
+
type: 'integer',
|
|
244
|
+
default: 8,
|
|
245
|
+
min: 0,
|
|
246
|
+
max: 50000,
|
|
247
|
+
label: '保留单元格簇上限',
|
|
248
|
+
labelEn: 'Retained cluster limit',
|
|
249
|
+
description: '检查点保留多少个单元格簇的身份与观测,超过后最旧的簇折进区间摘要(其后续操作需要从日志重折)。默认与"保留最近单元格数"一致,所以检查点不会随会话长度线性增长;调大能保留更多无需重折的恢复证据,但检查点更大。填 0 表示不限制:不做按数量的折叠,只由载荷预算约束体积。',
|
|
250
|
+
descriptionEn: 'How many cell clusters keep their identity and observations in the checkpoint; older ones fold into an interval summary and need a log refold afterwards. It defaults to the retained-cells window, which is what keeps a long session from growing linearly; a larger value keeps more recovery evidence without a refold at the cost of a bigger checkpoint. 0 means unlimited: no count-based folding, with the payload budget as the only bound.',
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
key: 'capabilityPromptTextEnabled',
|
|
254
|
+
type: 'boolean',
|
|
255
|
+
default: true,
|
|
256
|
+
label: '提示文本',
|
|
257
|
+
labelEn: 'Prompt text',
|
|
258
|
+
description: '把「提示文本」页签里改写的固定句子应用到模型的请求、工具说明、能力方案与提示;关闭后这些渠道一律回到内置文本,已保存的改写保留但暂停生效。',
|
|
259
|
+
descriptionEn: 'Applies the sentences edited in the Prompt text tab to model requests, tool descriptions, the capability plan and tips. When off, those channels use the built-in text; saved edits are kept but paused.',
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
key: 'promptText',
|
|
263
|
+
type: 'string',
|
|
264
|
+
default: PROMPT_TEXT_DEFAULT,
|
|
265
|
+
label: '提示文本覆盖',
|
|
266
|
+
labelEn: 'Prompt text overrides',
|
|
267
|
+
description: '按槽位 id 覆盖插件写给模型的固定提示文本;空对象表示使用内置文本。诊断、信封与由配置派生的策略句始终只读。',
|
|
268
|
+
descriptionEn: 'Override the fixed prompt text the plugin writes for the model, keyed by slot id; an empty document keeps the built-in text. Diagnostics, message envelopes and configuration-derived policy sentences stay read-only.',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
key: 'unmeasuredCellLimit',
|
|
272
|
+
type: 'integer',
|
|
273
|
+
default: 2,
|
|
274
|
+
min: 1,
|
|
275
|
+
max: Number.MAX_SAFE_INTEGER,
|
|
276
|
+
label: '连续未测量 cell 丢弃阈值',
|
|
277
|
+
labelEn: 'Consecutive unmeasured cells before the worker is dropped',
|
|
278
|
+
description: '连续第几个未测量 cell 触发丢弃 live worker(默认 2:第一个保留,第二个丢弃);已证明的 frontier 保留给下一 cell 重放。',
|
|
279
|
+
descriptionEn: 'The consecutive unmeasured cell that drops the live worker (default 2: the first is kept, the second drops it). The proved frontier stays replayable for the next cell.',
|
|
280
|
+
},
|
|
218
281
|
{
|
|
219
282
|
key: 'officialReplPriority',
|
|
220
283
|
type: 'boolean',
|
|
@@ -288,6 +351,50 @@ export const CONFIG_FIELDS = Object.freeze([
|
|
|
288
351
|
description: '限制单个 cell 的日志和返回结果的合计大小。',
|
|
289
352
|
descriptionEn: 'Limits the combined size of one cell\'s logs and returned result.',
|
|
290
353
|
},
|
|
354
|
+
{
|
|
355
|
+
key: 'sampleBoundMs',
|
|
356
|
+
type: 'integer',
|
|
357
|
+
default: 1_000,
|
|
358
|
+
min: 1,
|
|
359
|
+
max: MAX_TIMER_DELAY_MS,
|
|
360
|
+
label: '计算基线采样等待上限 (ms)',
|
|
361
|
+
labelEn: 'Compute-baseline sample wait limit (ms)',
|
|
362
|
+
description: '等待 worker 回答计算基线采样的上限;宿主忙碌时先读空 helper 通道再判断缺失,未观察到的采样按未测量 no-op 结算,是否保留 worker 由 unmeasuredCellLimit 决定;transport 报告退出、断连或无法发送时仍走 worker-exit 重置。',
|
|
363
|
+
descriptionEn: 'Bounds the wait for the worker compute-baseline answer. A busy host reads the helper channel out before concluding an answer is missing; an unobserved sample settles as an unmeasured no-op and unmeasuredCellLimit decides whether the worker stays; a transport-reported exit, disconnect or unsendable request keeps the worker-exit reset route.',
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
key: 'workerReclaimMs',
|
|
367
|
+
type: 'integer',
|
|
368
|
+
default: 5_000,
|
|
369
|
+
min: 1_000,
|
|
370
|
+
max: MAX_TIMER_DELAY_MS,
|
|
371
|
+
label: 'worker 回收确认上限 (ms)',
|
|
372
|
+
labelEn: 'Worker reclamation confirmation limit (ms)',
|
|
373
|
+
description: '停止 helper 的关闭阶段上限(至少 1000ms);exit 确认与其后的输出关闭各自以此为上界,release grace 会被压缩以给 kill 预留至少 1000ms 的确认窗口。worker 创建与关闭时读取当前配置。cell 触发的 session kernel exit 超时会保留 helper 并在会话释放前阻止后续 cell;startup、idle 或输出关闭路径的失败,以及 binding console 与 candidate helper,只保留实例并在释放时报告。',
|
|
374
|
+
descriptionEn: 'Bounds one close stage of a stopped helper (at least 1000 ms): exit confirmation and the following output close are each bounded by it, with the release grace capped so the kill keeps at least a 1000 ms confirmation window. Read from the current configuration when a worker is created or closed. A cell-driven session-kernel exit miss retains the helper and blocks later cells until the session is released; a startup, idle or output-close miss, and the binding console and candidate helpers, only retain the instance and report it at release.',
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
key: 'startupProbeMs',
|
|
378
|
+
type: 'integer',
|
|
379
|
+
default: 5_000,
|
|
380
|
+
min: 1,
|
|
381
|
+
max: MAX_TIMER_DELAY_MS,
|
|
382
|
+
label: 'REPL 启动探针上限 (ms)',
|
|
383
|
+
labelEn: 'REPL startup probe limit (ms)',
|
|
384
|
+
description: 'worker 启动时校验 REPL 契约的等待上限;超时按启动失败结算,不执行任何 cell。worker 创建时读取当前配置。',
|
|
385
|
+
descriptionEn: 'Bounds the worker startup check of the REPL contract. A timeout settles as a startup failure and no cell executes. Read from the current configuration when a worker is created.',
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
key: 'executionSeamTimeoutMs',
|
|
389
|
+
type: 'integer',
|
|
390
|
+
default: 30_000,
|
|
391
|
+
min: 1,
|
|
392
|
+
max: MAX_TIMER_DELAY_MS,
|
|
393
|
+
label: '等待宿主执行入口上限 (ms)',
|
|
394
|
+
labelEn: 'Host execution-seam wait limit (ms)',
|
|
395
|
+
description: '激活时等待宿主注册执行入口的上限;超时把当前部署报告为不支持。只在插件激活或重载时读取,运行中的修改在下次激活生效。',
|
|
396
|
+
descriptionEn: 'Bounds the activation wait for the host to register its execution seam. A timeout reports the deployment as unsupported. Read only when the plugin activates or reloads; a live change applies at the next activation.',
|
|
397
|
+
},
|
|
291
398
|
{
|
|
292
399
|
key: 'maxValueNodes',
|
|
293
400
|
type: 'integer',
|
|
@@ -376,7 +483,7 @@ export const CONFIG_GROUPS = Object.freeze([
|
|
|
376
483
|
key: 'recovery',
|
|
377
484
|
label: '状态与恢复',
|
|
378
485
|
labelEn: 'State and recovery',
|
|
379
|
-
fields: Object.freeze(['durableReplay', 'officialReplPriority', 'tipsEnabled', 'tipCooldownMessages', 'tipEscalationFailures']),
|
|
486
|
+
fields: Object.freeze(['durableReplay', 'unmeasuredCellLimit', 'officialReplPriority', 'tipsEnabled', 'tipCooldownMessages', 'tipEscalationFailures']),
|
|
380
487
|
},
|
|
381
488
|
{
|
|
382
489
|
key: 'extensions',
|
|
@@ -406,6 +513,10 @@ export const CONFIG_GROUPS = Object.freeze([
|
|
|
406
513
|
'maxOldGenerationSizeMb',
|
|
407
514
|
'maxNestedRunCodeDepth',
|
|
408
515
|
'maxOutputBytes',
|
|
516
|
+
'sampleBoundMs',
|
|
517
|
+
'workerReclaimMs',
|
|
518
|
+
'startupProbeMs',
|
|
519
|
+
'executionSeamTimeoutMs',
|
|
409
520
|
'maxValueNodes',
|
|
410
521
|
'maxValueEdges',
|
|
411
522
|
'maxValueArrayLength',
|
|
@@ -414,7 +525,7 @@ export const CONFIG_GROUPS = Object.freeze([
|
|
|
414
525
|
},
|
|
415
526
|
{ key: 'capabilities', label: '能力与上下文', labelEn: 'Capabilities and context',
|
|
416
527
|
fields: Object.freeze(['capabilityContextEnabled', 'capabilityManagementEnabled', 'capabilityBaseline', 'capabilityOverrides',
|
|
417
|
-
'capabilityStatistics', 'capabilityPolicy', 'capabilityRecoveryEnabled', 'modelPreviewEnabled', 'capabilityProfiles', 'capabilityBudgetBytes', 'capabilityDirectoryBytes', 'capabilityRecoveryBytes', 'modelPreviewTokens']) },
|
|
528
|
+
'capabilityStatistics', 'capabilityPolicy', 'capabilityRecoveryEnabled', 'modelPreviewEnabled', 'capabilityProfiles', 'capabilityBudgetBytes', 'capabilityDirectoryBytes', 'capabilityRecoveryBytes', 'modelPreviewTokens', 'retentionPayloadRuns', 'retentionPayloadBytes', 'retentionClusters', 'capabilityPromptTextEnabled', 'promptText']) },
|
|
418
529
|
])
|
|
419
530
|
|
|
420
531
|
/** Functional modules share the same section order across every settings entry. */
|
|
@@ -16,10 +16,8 @@ import {
|
|
|
16
16
|
import { userBindingsConfiguredContext } from './user-bindings.js'
|
|
17
17
|
import { BINDING_AUTHORING_SDK } from './user-binding-authoring.js'
|
|
18
18
|
import { executionPolicies } from './binding-update-policy.js'
|
|
19
|
+
import { PROMPT_SLOT_IDS, promptAssetText, resolvePromptTextConfig } from './prompt-text.js'
|
|
19
20
|
|
|
20
|
-
const RUN_CODE_TOOL_DESCRIPTION = 'Evaluate the next TypeScript cell in this session-bound persistent REPL, reusing earlier top-level bindings. Use `code` for the cell and `description` for its short UI summary. Successful image-bearing subtool results are attached after the cell.'
|
|
21
|
-
const RUN_CODE_CODE_DESCRIPTION = 'Code for the next PTC TypeScript cell, following the selected language conventions.'
|
|
22
|
-
const RUN_CODE_DESCRIPTION_DESCRIPTION = 'Short active-voice summary of what this cell does, 5-10 words (shown in the UI).'
|
|
23
21
|
const CODE_TRANSPORT_INSTRUCTION = '`run_code` is the only tool callable directly. Call `tools.edit_run_code` and other SDK tools from inside a program. An accepted edit runs after the current cell finishes.'
|
|
24
22
|
const PTC_COLLAPSE_SECTION_NAMES = Object.freeze(['tools:ptc-only', 'tools:code-only'])
|
|
25
23
|
const INVALID_TOOL_JSON_FAILURE = Object.freeze({
|
|
@@ -29,7 +27,7 @@ const INVALID_TOOL_JSON_FAILURE = Object.freeze({
|
|
|
29
27
|
const MALFORMED_TOOL_REJECTION_ID = 'dsh-ptc-plus-malformed-tool-json'
|
|
30
28
|
const MALFORMED_TOOL_REJECTION_ARGUMENTS = '{"dsh_ptc_plus_malformed_tool_json":'
|
|
31
29
|
|
|
32
|
-
function adaptRunCodeSchema(tool) {
|
|
30
|
+
function adaptRunCodeSchema(tool, promptText) {
|
|
33
31
|
const parameters = tool.parameters
|
|
34
32
|
const properties = isRecord(parameters) ? parameters.properties : undefined
|
|
35
33
|
const code = isRecord(properties) ? properties.code : undefined
|
|
@@ -41,13 +39,13 @@ function adaptRunCodeSchema(tool) {
|
|
|
41
39
|
}
|
|
42
40
|
return {
|
|
43
41
|
...tool,
|
|
44
|
-
description:
|
|
42
|
+
description: promptAssetText(promptText, PROMPT_SLOT_IDS.runCodeDescription),
|
|
45
43
|
parameters: {
|
|
46
44
|
...parameters,
|
|
47
45
|
properties: {
|
|
48
46
|
...properties,
|
|
49
|
-
code: { ...code, description:
|
|
50
|
-
description: { ...description, description:
|
|
47
|
+
code: { ...code, description: promptAssetText(promptText, PROMPT_SLOT_IDS.runCodeCodeArg) },
|
|
48
|
+
description: { ...description, description: promptAssetText(promptText, PROMPT_SLOT_IDS.runCodeDescriptionArg) },
|
|
51
49
|
},
|
|
52
50
|
},
|
|
53
51
|
}
|
|
@@ -472,11 +470,13 @@ export function createDirectSurfaceOwner({
|
|
|
472
470
|
let disposed = false
|
|
473
471
|
let currentCanonicalizeToolCalls = canonicalizeToolCalls
|
|
474
472
|
let currentAutoDescribeRunCode = runtimeConfig.autoDescribeRunCode
|
|
473
|
+
let currentPromptText = resolvePromptTextConfig(runtimeConfig)
|
|
475
474
|
let currentLanguageSemantics = executionPolicies(runtimeConfig).languageSemantics
|
|
476
475
|
const tipConfig = {
|
|
477
476
|
enabled: runtimeConfig.tipsEnabled,
|
|
478
477
|
cooldownMessages: runtimeConfig.tipCooldownMessages,
|
|
479
478
|
escalationFailures: runtimeConfig.tipEscalationFailures,
|
|
479
|
+
get prompt() { return currentPromptText },
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
const sessionOwner = (id) => {
|
|
@@ -561,6 +561,7 @@ export function createDirectSurfaceOwner({
|
|
|
561
561
|
currentLanguageSemantics = executionPolicies(nextConfig).languageSemantics
|
|
562
562
|
currentCanonicalizeToolCalls = nextConfig.canonicalizeToolCalls
|
|
563
563
|
currentAutoDescribeRunCode = nextConfig.autoDescribeRunCode
|
|
564
|
+
currentPromptText = resolvePromptTextConfig(nextConfig)
|
|
564
565
|
cordisRecovery.reconfigure(nextConfig.cordisToolsEnabled)
|
|
565
566
|
tipConfig.enabled = nextConfig.tipsEnabled
|
|
566
567
|
tipConfig.cooldownMessages = nextConfig.tipCooldownMessages
|
|
@@ -628,10 +629,11 @@ export function createDirectSurfaceOwner({
|
|
|
628
629
|
const userBindings = sessionPtc ? await userBindingsForAgent(agent) : undefined
|
|
629
630
|
if (!isCurrent()) return assembly
|
|
630
631
|
const runCode = tools.find(tool => tool?.name === RUN_CODE)
|
|
632
|
+
const promptText = currentPromptText
|
|
631
633
|
let directTools = sessionPtc ? capabilityEnvironment?.visibleSchemas(agent, tools) ?? tools : tools
|
|
632
634
|
if (sessionPtcProjection) {
|
|
633
635
|
editTransport.ensureInstalled(agent)
|
|
634
|
-
directTools = [adaptRunCodeSchema(runCode)]
|
|
636
|
+
directTools = [adaptRunCodeSchema(runCode, promptText)]
|
|
635
637
|
}
|
|
636
638
|
const runtimeContexts = sessionPtc
|
|
637
639
|
? sessionRuntimeContexts(agent, tipConfig, {
|
|
@@ -641,7 +643,9 @@ export function createDirectSurfaceOwner({
|
|
|
641
643
|
},
|
|
642
644
|
})
|
|
643
645
|
: { contexts: [] }
|
|
644
|
-
const bindingDefaults = userBindings === undefined ? undefined : userBindingsConfiguredContext(userBindings
|
|
646
|
+
const bindingDefaults = userBindings === undefined ? undefined : userBindingsConfiguredContext(userBindings, {
|
|
647
|
+
entryLabel: promptAssetText(promptText, PROMPT_SLOT_IDS.userBindingsEntryLabel),
|
|
648
|
+
})
|
|
645
649
|
const contexts = [
|
|
646
650
|
...runtimeContexts.contexts,
|
|
647
651
|
...(bindingDefaults === undefined ? [] : [bindingDefaults]),
|
|
@@ -684,7 +688,7 @@ export function createDirectSurfaceOwner({
|
|
|
684
688
|
tools: sessionPtcProjection
|
|
685
689
|
? directTools
|
|
686
690
|
: directTools.map(tool => tool?.name === RUN_CODE
|
|
687
|
-
? adaptRunCodeSchema(tool)
|
|
691
|
+
? adaptRunCodeSchema(tool, promptText)
|
|
688
692
|
: tool),
|
|
689
693
|
sections,
|
|
690
694
|
}
|
|
@@ -7,10 +7,12 @@ import {
|
|
|
7
7
|
REWRITES_KEY,
|
|
8
8
|
isCanonicalSequence,
|
|
9
9
|
liveToolCallSeq,
|
|
10
|
+
normalizeDeferredEditSettlement,
|
|
10
11
|
normalizeJournal,
|
|
11
12
|
normalizeRecoveryBoundaries,
|
|
12
13
|
normalizeRewrites,
|
|
13
14
|
validatedRewrites,
|
|
15
|
+
withDiagnosticWording,
|
|
14
16
|
} from './session-journal.js'
|
|
15
17
|
import {
|
|
16
18
|
EXPECTED_TARGET_CALL_SEQ,
|
|
@@ -18,6 +20,11 @@ import {
|
|
|
18
20
|
editRunCodeSchema,
|
|
19
21
|
} from './rejected-cell-editor.js'
|
|
20
22
|
import { projectSessionLog } from './session-log-view.js'
|
|
23
|
+
import { refoldEditableSettlement } from './session-journal-recovery.js'
|
|
24
|
+
import {
|
|
25
|
+
PRUNED_EDIT, PRUNED_EDIT_EN, PRUNED_SUMMARY_MISMATCH, PRUNED_SUMMARY_MISMATCH_EN,
|
|
26
|
+
} from './projection-retention.js'
|
|
27
|
+
import { PROMPT_SLOT_IDS, promptAssetText } from './prompt-text.js'
|
|
21
28
|
import { sessionEvents } from './session-events.js'
|
|
22
29
|
import { deepEqualJson, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
|
23
30
|
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
|
@@ -72,8 +79,35 @@ function nestedEditStart(sessionLog, exec) {
|
|
|
72
79
|
|| event.data.rootCallId !== exec.rootCallId
|
|
73
80
|
|| event.data.parentCallId !== exec.rootCallId
|
|
74
81
|
|| !deepEqualJson(event.data.arguments, exec.arguments)) return undefined
|
|
75
|
-
const
|
|
76
|
-
|
|
82
|
+
const slice = { session: { events: events.slice(0, index + 1) } }
|
|
83
|
+
const view = projectSessionLog(slice)
|
|
84
|
+
let target = view.editableRun
|
|
85
|
+
// A dropped cluster is not gone for good: the log still holds the events, so the
|
|
86
|
+
// edit path refolds that slice with pruning disabled and reads the cell from
|
|
87
|
+
// there. Only when the log itself cannot supply it does the edit report that the
|
|
88
|
+
// retention window cut it.
|
|
89
|
+
if (target === undefined && view.retentionSummary !== undefined) {
|
|
90
|
+
const refolded = refoldEditableSettlement(events.slice(0, index + 1), view.retentionSummary)
|
|
91
|
+
target = refolded.run
|
|
92
|
+
// The summary is the checkpoint's only record of a dropped cluster; the log is
|
|
93
|
+
// the authority. When the two disagree the edit must not proceed on either.
|
|
94
|
+
//
|
|
95
|
+
// This call site cannot produce that disagreement: the summary and the refold
|
|
96
|
+
// come from the same fold of the same event slice, and the run the refold reads
|
|
97
|
+
// is the one the pruned fold pins, so `refoldEditableSettlement` never finds a
|
|
98
|
+
// summary record for it. The comparison stays for the exported contract, which
|
|
99
|
+
// covers both outcomes in test/projection-retention.test.js.
|
|
100
|
+
/* c8 ignore start -- same-fold determinism: the call site cannot disagree with itself (see above) */
|
|
101
|
+
if (refolded.mismatch) {
|
|
102
|
+
return { startSeq: event.seq, rootCallSeq, target: undefined, retentionSummary: view.retentionSummary,
|
|
103
|
+
summaryMismatch: true }
|
|
104
|
+
}
|
|
105
|
+
/* c8 ignore stop */
|
|
106
|
+
}
|
|
107
|
+
// The summary travels with the target so an unavailable edit can say why it is
|
|
108
|
+
// unavailable: the cell fell outside the retention window instead of never
|
|
109
|
+
// existing.
|
|
110
|
+
return { startSeq: event.seq, rootCallSeq, target, retentionSummary: view.retentionSummary }
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
function unavailableDerivedResult(inner) {
|
|
@@ -118,6 +152,7 @@ export function createEditTransportOwner(ctx, {
|
|
|
118
152
|
sessionId,
|
|
119
153
|
toolSchemasForAgent,
|
|
120
154
|
readSessionLog,
|
|
155
|
+
promptText,
|
|
121
156
|
}) {
|
|
122
157
|
const editClaims = new Map()
|
|
123
158
|
const pendingAcceptances = new Map()
|
|
@@ -137,7 +172,7 @@ export function createEditTransportOwner(ctx, {
|
|
|
137
172
|
}
|
|
138
173
|
}
|
|
139
174
|
|
|
140
|
-
const definition = editRunCodeSchema()
|
|
175
|
+
const definition = editRunCodeSchema(promptAssetText(promptText, PROMPT_SLOT_IDS.editRunCodeDescription))
|
|
141
176
|
const derivedMetadata = (derived) => {
|
|
142
177
|
const meta = {
|
|
143
178
|
[JOURNAL_KEY]: normalizeJournal(derived.journal),
|
|
@@ -164,9 +199,12 @@ export function createEditTransportOwner(ctx, {
|
|
|
164
199
|
derived.draftCapability,
|
|
165
200
|
presentationGeneration,
|
|
166
201
|
)
|
|
202
|
+
// The derived settlement records its own diagnostic wording, so a replay reads
|
|
203
|
+
// the same pairing the root path publishes.
|
|
204
|
+
const withWording = withDiagnosticWording(withDraft, withDraft[JOURNAL_KEY])
|
|
167
205
|
return derived.replMemory === undefined
|
|
168
|
-
?
|
|
169
|
-
: withReplMemorySnapshot(
|
|
206
|
+
? withWording
|
|
207
|
+
: withReplMemorySnapshot(withWording, derived.replMemory, presentationGeneration)
|
|
170
208
|
}
|
|
171
209
|
definition.output = {
|
|
172
210
|
schema: {
|
|
@@ -193,8 +231,17 @@ export function createEditTransportOwner(ctx, {
|
|
|
193
231
|
throw new Error('ptc-plus: edit_run_code requires a unique persisted PTC sub-call start')
|
|
194
232
|
}
|
|
195
233
|
const target = start.target
|
|
234
|
+
/* c8 ignore start -- reachable only through `nestedEditStart`'s mismatch, which cannot disagree with itself (see there) */
|
|
235
|
+
if (start.summaryMismatch === true) {
|
|
236
|
+
return { accepted: false, scheduled: false,
|
|
237
|
+
reason: `${PRUNED_SUMMARY_MISMATCH} / ${PRUNED_SUMMARY_MISMATCH_EN}` }
|
|
238
|
+
}
|
|
239
|
+
/* c8 ignore stop */
|
|
196
240
|
if (target?.source === undefined || target.callSeq === undefined) {
|
|
197
|
-
return { accepted: false, scheduled: false,
|
|
241
|
+
return { accepted: false, scheduled: false,
|
|
242
|
+
reason: start.retentionSummary === undefined
|
|
243
|
+
? 'no run_code cell is currently eligible for safe editing'
|
|
244
|
+
: `${PRUNED_EDIT} / ${PRUNED_EDIT_EN}` }
|
|
198
245
|
}
|
|
199
246
|
const edited = editRejectedCell(args, target.source)
|
|
200
247
|
if (!edited.edited) {
|
|
@@ -292,7 +339,7 @@ export function createEditTransportOwner(ctx, {
|
|
|
292
339
|
const journalValue = isRecord(metadata) && Object.hasOwn(metadata, JOURNAL_KEY)
|
|
293
340
|
? metadata[JOURNAL_KEY] : undefined
|
|
294
341
|
const journal = journalValue === undefined ? undefined : normalizeJournal(journalValue)
|
|
295
|
-
if (journal === undefined
|
|
342
|
+
if (journal === undefined
|
|
296
343
|
|| inner.isError !== (journal.completion?.kind === 'throw' || journal.status === 'discarded')) {
|
|
297
344
|
this.finish(false)
|
|
298
345
|
return { outcome: unavailableDerivedResult(inner), presentation: derivedPresentation(inner) }
|
|
@@ -310,16 +357,34 @@ export function createEditTransportOwner(ctx, {
|
|
|
310
357
|
code: edited.code,
|
|
311
358
|
description: edited.description,
|
|
312
359
|
}
|
|
360
|
+
const metaValue = derivedMetadata(derived)
|
|
361
|
+
let settlement
|
|
362
|
+
try {
|
|
363
|
+
settlement = normalizeDeferredEditSettlement(metaValue, target.callSeq)
|
|
364
|
+
} catch {
|
|
365
|
+
// Metadata this relation cannot prove must not reach the root result.
|
|
366
|
+
this.finish(false)
|
|
367
|
+
return { outcome: unavailableDerivedResult(inner), presentation: derivedPresentation(inner) }
|
|
368
|
+
}
|
|
369
|
+
const provenance = {
|
|
370
|
+
startSeq: start.startSeq,
|
|
371
|
+
executionStartSeq: executionStart.seq,
|
|
372
|
+
rootCallSeq: start.rootCallSeq,
|
|
373
|
+
subCallId: exec.callId,
|
|
374
|
+
meta: metaValue,
|
|
375
|
+
}
|
|
376
|
+
if (settlement.unexecuted === true) {
|
|
377
|
+
// The accepted edit is proved by a settlement that never ran. Publishing
|
|
378
|
+
// the relation keeps the root cell provable, while the outcome and the
|
|
379
|
+
// unexecuted journal keep the revision from executing, from publishing a
|
|
380
|
+
// binding and from becoming the next edit target.
|
|
381
|
+
this.finish(false)
|
|
382
|
+
return { outcome: unavailableDerivedResult(inner), presentation: derivedPresentation(inner), provenance }
|
|
383
|
+
}
|
|
313
384
|
return {
|
|
314
385
|
outcome: derivedEditResult(inner),
|
|
315
386
|
presentation: derivedPresentation(inner),
|
|
316
|
-
provenance
|
|
317
|
-
startSeq: start.startSeq,
|
|
318
|
-
executionStartSeq: executionStart.seq,
|
|
319
|
-
rootCallSeq: start.rootCallSeq,
|
|
320
|
-
subCallId: exec.callId,
|
|
321
|
-
meta: derivedMetadata(derived),
|
|
322
|
-
},
|
|
387
|
+
provenance,
|
|
323
388
|
}
|
|
324
389
|
},
|
|
325
390
|
}
|
|
@@ -5,14 +5,46 @@ import { PassThrough } from 'node:stream'
|
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
import { helperProcessEnvironment, isElectronHost } from './worker-environment.js'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Bound on the wait for one on-demand utilization answer.
|
|
10
|
+
*
|
|
11
|
+
* The helper answers by reading the inner worker's counters synchronously, so
|
|
12
|
+
* this bound covers delivery rather than helper work. It is deliberately not
|
|
13
|
+
* the shutdown release grace below: how long a stopping helper may keep running
|
|
14
|
+
* must not decide how long a cell waits for its measurement.
|
|
15
|
+
*/
|
|
16
|
+
export const ISOLATED_SAMPLE_MS = 1000
|
|
17
|
+
/**
|
|
18
|
+
* Sampling failure code for a sample whose answer this transport could not
|
|
19
|
+
* observe. The transport reports only that fact: whether the worker stays usable
|
|
20
|
+
* belongs to the caller, which alone knows how many consecutive measurements it
|
|
21
|
+
* may lose before the worker has to be replaced.
|
|
22
|
+
*/
|
|
23
|
+
export const UNANSWERED_SAMPLE_CODE = 'ptc-sample-unanswered'
|
|
24
|
+
/**
|
|
25
|
+
* Helper messages one sample may read out before it gives up on the answer.
|
|
26
|
+
*
|
|
27
|
+
* Reading stops at the first event-loop turn that delivers nothing, so this
|
|
28
|
+
* bound only ends a backlog larger than the bound itself, where the answer
|
|
29
|
+
* cannot be reached by reading until the channel empties.
|
|
30
|
+
*/
|
|
31
|
+
const SAMPLE_DRAIN_TURNS = 4096
|
|
8
32
|
/**
|
|
9
33
|
* Fallback bound for a helper that answers the stop request and then keeps the
|
|
10
34
|
* process alive with unrelated work of its own.
|
|
11
35
|
*/
|
|
12
36
|
const ISOLATED_RELEASE_MS = 1000
|
|
13
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Default bound for each close stage of one helper and the transport default.
|
|
39
|
+
*
|
|
40
|
+
* Exit confirmation and the output close that follows are each bounded by it;
|
|
41
|
+
* the fixed release grace is capped so the bound still leaves a confirmation
|
|
42
|
+
* window for the kill. A transport instance carries a configurable
|
|
43
|
+
* `reclaimBoundMs`, and the close path reads that instance bound instead of
|
|
44
|
+
* this default.
|
|
45
|
+
*/
|
|
14
46
|
const ISOLATED_STOP_MS = 5000
|
|
15
|
-
/**
|
|
47
|
+
/** Default bound on the helper's output pipes really ending after its process exited. */
|
|
16
48
|
const ISOLATED_OUTPUT_MS = 5000
|
|
17
49
|
|
|
18
50
|
export function isolatedWorkerEntry(entry) {
|
|
@@ -36,7 +68,7 @@ export function isolatedWorkerEntry(entry) {
|
|
|
36
68
|
* so a kernel that never answers is reclaimed without ending the host.
|
|
37
69
|
*/
|
|
38
70
|
export class IsolatedWorker extends EventEmitter {
|
|
39
|
-
constructor({ helper, entry, workerData, resourceLimits, env, protocol = 'kernel' }) {
|
|
71
|
+
constructor({ helper, entry, workerData, resourceLimits, env, protocol = 'kernel', reclaimBoundMs = ISOLATED_STOP_MS }) {
|
|
40
72
|
super()
|
|
41
73
|
// The entry follows the worker constructor's contract: a URL or a string
|
|
42
74
|
// filename. Anything else fails here, before a helper process exists.
|
|
@@ -58,6 +90,29 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
58
90
|
this.utilization = { idle: 0, active: 0 }
|
|
59
91
|
this.utilizationSequence = 0
|
|
60
92
|
this.utilizationRequests = new Map()
|
|
93
|
+
/**
|
|
94
|
+
* Count of messages this helper has produced.
|
|
95
|
+
*
|
|
96
|
+
* Reading a sample uses it to tell one event-loop turn that delivered
|
|
97
|
+
* something from one that delivered nothing; the count says nothing about
|
|
98
|
+
* when the helper wrote a message.
|
|
99
|
+
*/
|
|
100
|
+
this.helperMessages = 0
|
|
101
|
+
/**
|
|
102
|
+
* Bound this instance waits for one utilization answer.
|
|
103
|
+
*
|
|
104
|
+
* It is read per request, so a transport user may shorten or extend the wait
|
|
105
|
+
* for its own environment without changing the shutdown release grace.
|
|
106
|
+
*/
|
|
107
|
+
this.sampleBoundMs = ISOLATED_SAMPLE_MS
|
|
108
|
+
/**
|
|
109
|
+
* Bound this instance's close path waits for the killed helper to be
|
|
110
|
+
* confirmed gone and for its output pipes to end.
|
|
111
|
+
*
|
|
112
|
+
* It is read when the close starts, so a transport user may widen the wait
|
|
113
|
+
* for its own platform without changing the release grace.
|
|
114
|
+
*/
|
|
115
|
+
this.reclaimBoundMs = reclaimBoundMs
|
|
61
116
|
this.pendingErrors = []
|
|
62
117
|
this.performance = {
|
|
63
118
|
/** Utilization reported by the helper, in the shape callers already use. */
|
|
@@ -124,6 +179,7 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
124
179
|
}
|
|
125
180
|
})
|
|
126
181
|
this.child.on('message', message => {
|
|
182
|
+
this.helperMessages += 1
|
|
127
183
|
if (message?.type === 'child-error') {
|
|
128
184
|
const error = new Error(message.message)
|
|
129
185
|
if (message.name !== undefined) error.name = message.name
|
|
@@ -139,11 +195,14 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
139
195
|
}
|
|
140
196
|
if (message?.type === 'inner-worker-exit') { this.emit('inner-worker-exit', message.code); return }
|
|
141
197
|
if (message?.type === 'utilization-sample') {
|
|
198
|
+
// An answer for a request this transport already concluded is still the
|
|
199
|
+
// helper doing its work: keep the freshest value it reported.
|
|
200
|
+
this.utilization = message.utilization
|
|
142
201
|
const pending = this.utilizationRequests.get(message.id)
|
|
143
202
|
if (pending !== undefined) {
|
|
144
203
|
this.utilizationRequests.delete(message.id)
|
|
145
204
|
clearTimeout(pending.timer)
|
|
146
|
-
|
|
205
|
+
if (pending.decision !== undefined) clearImmediate(pending.decision)
|
|
147
206
|
pending.resolve(message.utilization)
|
|
148
207
|
}
|
|
149
208
|
return
|
|
@@ -225,25 +284,81 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
225
284
|
}
|
|
226
285
|
const id = ++this.utilizationSequence
|
|
227
286
|
return new Promise((resolve, reject) => {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
287
|
+
const pending = { resolve, reject, timer: undefined, decision: undefined }
|
|
288
|
+
pending.timer = setTimeout(() => {
|
|
289
|
+
// A blocked host runs its expired timers before it reads its IPC pipe, so
|
|
290
|
+
// the answer may be waiting unread here. Read the channel out first and
|
|
291
|
+
// only then conclude that no answer exists.
|
|
292
|
+
pending.decision = setImmediate(() => this.#readOutSample(id, pending))
|
|
293
|
+
}, this.sampleBoundMs)
|
|
294
|
+
this.utilizationRequests.set(id, pending)
|
|
233
295
|
try {
|
|
234
296
|
this.child.send({ type: 'sample-utilization', id })
|
|
235
297
|
} catch (error) {
|
|
236
|
-
clearTimeout(timer)
|
|
298
|
+
clearTimeout(pending.timer)
|
|
237
299
|
this.utilizationRequests.delete(id)
|
|
238
300
|
reject(error)
|
|
239
301
|
}
|
|
240
302
|
})
|
|
241
303
|
}
|
|
242
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Read the helper's channel out before one unanswered sample is concluded.
|
|
307
|
+
*
|
|
308
|
+
* Node delivers one helper message per event-loop turn, and the answer this
|
|
309
|
+
* request waits for is written after everything the helper wrote before it, so
|
|
310
|
+
* it can sit behind queued messages. Each turn therefore either delivers the
|
|
311
|
+
* answer or another message the helper wrote; only a turn that delivers no
|
|
312
|
+
* message shows that the channel holds nothing more this read could observe.
|
|
313
|
+
*
|
|
314
|
+
* A message the parser still holds in parts is not delivered on any of those
|
|
315
|
+
* turns, so a partially received message ahead of the answer delays it past
|
|
316
|
+
* this read-out; the sample then reports no answer, which is all this
|
|
317
|
+
* transport can prove about it.
|
|
318
|
+
*/
|
|
319
|
+
#readOutSample(id, pending) {
|
|
320
|
+
let delivered = this.helperMessages
|
|
321
|
+
let turns = 0
|
|
322
|
+
const turn = () => {
|
|
323
|
+
if (this.utilizationRequests.get(id) !== pending) return
|
|
324
|
+
if (turns >= SAMPLE_DRAIN_TURNS) {
|
|
325
|
+
this.utilizationRequests.delete(id)
|
|
326
|
+
pending.decision = undefined
|
|
327
|
+
pending.reject(this.#unansweredSample('backlog'))
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if (this.helperMessages === delivered) {
|
|
331
|
+
this.utilizationRequests.delete(id)
|
|
332
|
+
pending.decision = undefined
|
|
333
|
+
pending.reject(this.#unansweredSample('silent-channel'))
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
delivered = this.helperMessages
|
|
337
|
+
turns += 1
|
|
338
|
+
pending.decision = setImmediate(turn)
|
|
339
|
+
}
|
|
340
|
+
pending.decision = setImmediate(turn)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Report one sample whose answer this transport could not observe.
|
|
345
|
+
*
|
|
346
|
+
* The transport states the observation only. Whether the caller keeps its
|
|
347
|
+
* worker is decided where the consecutive measurement failures are counted.
|
|
348
|
+
*/
|
|
349
|
+
#unansweredSample(reason) {
|
|
350
|
+
const error = new Error(reason === 'backlog'
|
|
351
|
+
? `kernel helper wrote at least ${SAMPLE_DRAIN_TURNS} messages ahead of this utilization sample, so its answer could not be reached before the read-out bound`
|
|
352
|
+
: 'kernel helper did not answer utilization sampling before its deadline')
|
|
353
|
+
error.code = UNANSWERED_SAMPLE_CODE
|
|
354
|
+
return error
|
|
355
|
+
}
|
|
356
|
+
|
|
243
357
|
/** Fail every request that still needs the helper's utilization answer. */
|
|
244
358
|
#rejectUtilizationRequests(error) {
|
|
245
359
|
for (const pending of this.utilizationRequests.values()) {
|
|
246
360
|
clearTimeout(pending.timer)
|
|
361
|
+
if (pending.decision !== undefined) clearImmediate(pending.decision)
|
|
247
362
|
pending.reject(error)
|
|
248
363
|
}
|
|
249
364
|
this.utilizationRequests.clear()
|
|
@@ -268,12 +383,14 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
268
383
|
|
|
269
384
|
async #stop() {
|
|
270
385
|
if (this.exited) return
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
386
|
+
// The configured bound is this close's exit-confirmation deadline, anchored
|
|
387
|
+
// at the close start so an acknowledgement never postpones the kill. The
|
|
388
|
+
// fixed release grace is capped to leave a grace-sized confirmation window
|
|
389
|
+
// for the kill, so a bound at or just above the grace is never consumed
|
|
390
|
+
// before the kill; the output pipes then get the same bound from their exit.
|
|
274
391
|
const started = Date.now()
|
|
275
|
-
const
|
|
276
|
-
const
|
|
392
|
+
const confirmBy = started + this.reclaimBoundMs
|
|
393
|
+
const releaseAt = started + Math.min(ISOLATED_RELEASE_MS, Math.max(0, this.reclaimBoundMs - ISOLATED_RELEASE_MS))
|
|
277
394
|
const exited = this.exitedPromise
|
|
278
395
|
const onReleased = message => {
|
|
279
396
|
if (message?.type === 'worker-message' && message.value?.type === 'shutdown-released') this.released = true
|
|
@@ -287,7 +404,7 @@ export class IsolatedWorker extends EventEmitter {
|
|
|
287
404
|
} catch {
|
|
288
405
|
// The channel is closed; the kill path below owns the instance.
|
|
289
406
|
}
|
|
290
|
-
if (!await leftWithin(this, remaining(
|
|
407
|
+
if (!await leftWithin(this, remaining(releaseAt))) {
|
|
291
408
|
let killFailure
|
|
292
409
|
if (!this.exited) {
|
|
293
410
|
try {
|
|
@@ -410,7 +527,7 @@ export class IsolatedOwner {
|
|
|
410
527
|
// synchronously finish or throw and clear the marker for a later retry.
|
|
411
528
|
const settling = Promise.resolve().then(async () => {
|
|
412
529
|
if (record.neverStarted !== true && record.transport !== undefined) {
|
|
413
|
-
const closed = await outputClosed(record.transport, ISOLATED_OUTPUT_MS)
|
|
530
|
+
const closed = await outputClosed(record.transport, record.transport?.reclaimBoundMs ?? ISOLATED_OUTPUT_MS)
|
|
414
531
|
if (!closed) {
|
|
415
532
|
// Output never drained: keep the instance and say so instead of
|
|
416
533
|
// pretending the pipes ended naturally.
|