Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Please choose versions by [Semantic Versioning](http://semver.org/).
* MINOR version when you add functionality in a backwards-compatible manner, and
* PATCH version when you make backwards-compatible bug fixes.

## Unreleased

- fix: `vault-cli task set <task> claude_session_id <new>` appends the displaced owner to `metrics_sessions` before writing the new id, so a session handed off by a take-over stays resolvable by `session-self-exit.py` and `close-worker.py`. A first bind and a re-bind to the same value append nothing; the completion and spawn-failure clears are unchanged.

## v0.176.1

- fix: **`session-close`'s mode-2 closer is now a combinable menu, so a session that wants both the reflection and the self-improvement can answer for both on the attention board.** The closer emitted `pick — 1. /vault-cli:reflect · 2. /coding:self-improve · 3. nothing — close`, and a closer reaches the board with no `tool_input` at all, so the card declared no cardinality and rendered as radios with "(pick one)" — the operator could answer "1 + 2" in the console and not on the board. It now emits `pick any — …`, the combinable form the global closer rule defines: checkboxes on the board, with `nothing — close` last and exclusive, so the escape clears the other picks and any pick clears it. The producer half ships in `bborbe/claude` (the `Stop` hook that parses the marker and marks the last option exclusive) and the rendering half in `bborbe/attention-controller` v0.61.0 (the `exclusive` field and its board rule); an older board ignores the unknown field and renders the escape as an ordinary checkbox rather than rejecting the card. `commands/session-close.md` only — no Go change.
Expand Down
15 changes: 10 additions & 5 deletions docs/work-on-session-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,16 @@ The id is the load-bearing field — an id on disk means the session is resumabl
the metrics entry is analytics. The append is deliberately not in the agent
definition's `<critical_writes>` list.

**The generic write verbs refuse the field.** `task set`, `task add` and `task remove`
refuse `metrics_sessions` before any mutation, with no `--force` bypass, because `set`
stores a scalar and `add` / `remove` comma-split into a list of scalars — both shapes
the dedicated list reader discards. That refusal lives in
`pkg/ops/metrics_session_write.go`.
**The generic write verbs refuse a caller-supplied value for the field.** `task set`,
`task add` and `task remove` refuse a `metrics_sessions` value supplied by the caller,
before any mutation, with no `--force` bypass, because `set` stores a scalar and `add` /
`remove` comma-split into a list of scalars — both shapes the dedicated list reader
discards. That refusal lives in `pkg/ops/metrics_session_write.go`. The one internal
exception is the `claude_session_id` write: `task set <task> claude_session_id <new>`
that displaces a different non-empty owner appends that outgoing id to
`metrics_sessions` before storing the new id, so a taken-over session stays resolvable
by the exit and close paths. The append is internal to the session-id write, not a
caller-facing way to write the field.

**The removal is its own verb.** `vault-cli task remove-metrics-session <task-name>
<session-id>` removes **every** entry carrying that id while preserving every other
Expand Down
175 changes: 175 additions & 0 deletions integration/task_set_session_takeover_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) 2026 Benjamin Borbe All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package integration_test

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gexec"
"gopkg.in/yaml.v3"
)

var _ = Describe("task set session take-over", func() {
const (
sessionA = "AAAA"
sessionB = "BBBB"
)

var vaultPath, configPath string
var cleanup func()

// The fixture's frontmatter keys are alphabetical, because the storage writer
// re-serializes the whole block in key order on every write.
alphaFrontmatter := `---
page_type: task
status: in_progress
task_identifier: 11111111-1111-4111-8111-111111111111
---
body line
`

BeforeEach(func() {
vaultPath, configPath, cleanup = createTempVault(map[string]string{
"Alpha": alphaFrontmatter,
})
})

AfterEach(func() {
cleanup()
})

runCommand := func(args ...string) *gexec.Session {
fullArgs := append([]string{"--config", configPath, "--vault", "test"}, args...)
session, err := gexec.Start(
exec.Command(binPath, fullArgs...),
GinkgoWriter,
GinkgoWriter,
)
Expect(err).NotTo(HaveOccurred())
return session
}

bind := func(value string) {
session := runCommand("task", "set", "Alpha", "claude_session_id", value)
Eventually(session).Should(gexec.Exit(0))
}

readFile := func(path string) string {
content, err := os.ReadFile(path) //#nosec G304 -- test file
Expect(err).NotTo(HaveOccurred())
return string(content)
}

alphaPath := func() string {
return filepath.Join(vaultPath, "Tasks", "Alpha.md")
}

// getValue reads one field through the CLI and returns its rendered value.
getValue := func(key string) string {
session := runCommand("task", "get", "Alpha", key, "--output", "json")
Eventually(session).Should(gexec.Exit(0))
var parsed struct {
Value string `json:"value"`
}
Expect(json.Unmarshal(session.Out.Contents(), &parsed)).To(Succeed())
return parsed.Value
}

// frontmatterOf unmarshals only the frontmatter block; the markdown body
// would fail the whole-file unmarshal.
frontmatterOf := func(path string) map[string]any {
text := readFile(path)
Expect(strings.HasPrefix(text, "---\n")).To(BeTrue(), "no frontmatter opening")
rest := strings.TrimPrefix(text, "---\n")
end := strings.Index(rest, "\n---\n")
Expect(end).To(BeNumerically(">=", 0), "no frontmatter closing")
var parsed map[string]any
Expect(yaml.Unmarshal([]byte(rest[:end]), &parsed)).To(Succeed())
return parsed
}

// firstSessionEntry returns the session_id and started_at of the first
// metrics_sessions entry, as the YAML decoder resolved them.
firstSessionEntry := func(frontmatter map[string]any) (string, any) {
entries, ok := frontmatter["metrics_sessions"].([]any)
Expect(ok).To(BeTrue(), "metrics_sessions is not a list")
Expect(entries).NotTo(BeEmpty())
entry, ok := entries[0].(map[string]any)
Expect(ok).To(BeTrue(), "metrics_sessions entry is not a map")
sessionID, ok := entry["session_id"].(string)
Expect(ok).To(BeTrue(), "session_id is not a string")
return sessionID, entry["started_at"]
}

It("records the displaced owner and not the incoming one", func() {
bind(sessionA)

start := time.Now()
bind(sessionB)
end := time.Now()

Expect(getValue("claude_session_id")).To(Equal(sessionB))

rendered := getValue("metrics_sessions")
Expect(rendered).To(ContainSubstring("session_id:" + sessionA))
Expect(rendered).NotTo(ContainSubstring("session_id:" + sessionB))

content := readFile(alphaPath())
// The `- ` list-item prefix is load-bearing: a bare `session_id: BBBB`
// also matches the `claude_session_id: BBBB` line.
Expect(strings.Count(content, "- session_id: AAAA")).To(Equal(1))
Expect(strings.Count(content, "- session_id: BBBB")).To(Equal(0))

sessionID, startedAt := firstSessionEntry(frontmatterOf(alphaPath()))
Expect(sessionID).To(Equal(sessionA))
Expect(startedAt).NotTo(BeNil(), "started_at missing")

switch value := startedAt.(type) {
case string:
// The shared time type renders a midnight-UTC instant as a date only.
if len(value) == len("2006-01-02") {
Expect(value).To(Equal(start.UTC().Format("2006-01-02")))
return
}
parsed, err := time.Parse(time.RFC3339, value)
Expect(err).NotTo(HaveOccurred())
Expect(parsed).NotTo(BeZero())
Expect(parsed).To(BeTemporally(">=", start.Add(-time.Minute)))
Expect(parsed).To(BeTemporally("<=", end.Add(time.Minute)))
case time.Time:
Expect(value).NotTo(BeZero())
Expect(value).To(BeTemporally(">=", start.Add(-time.Minute)))
Expect(value).To(BeTemporally("<=", end.Add(time.Minute)))
default:
Fail("started_at has an unexpected type")
}
})

It("records nothing on a first bind", func() {
bind(sessionB)

Expect(getValue("claude_session_id")).To(Equal(sessionB))
Expect(getValue("metrics_sessions")).To(BeEmpty())
Expect(readFile(alphaPath())).NotTo(ContainSubstring("metrics_sessions:"))
})

It("records nothing when re-binding the same value", func() {
bind(sessionA)
bind(sessionB)
before := getValue("metrics_sessions")

bind(sessionB)

Expect(getValue("metrics_sessions")).To(Equal(before))
Expect(strings.Count(readFile(alphaPath()), "- session_id:")).To(Equal(1))
})
})
24 changes: 24 additions & 0 deletions pkg/ops/frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ func (o *frontmatterSetOperation) Execute(
// tell a cleared assignee from one that was never set.
previousAssignee := task.Assignee()

// The displaced owner must be appended before SetField overwrites it, so the
// single WriteTask below persists the new id and the recorded outgoing id
// together.
o.appendDisplacedSession(task, key, value)

// One-step close-out: when this invocation sets a close-out status, persist
// reason and successor first so both land in a single WriteTask. Fields are
// written only when provided; non-close-out targets never receive them.
Expand Down Expand Up @@ -170,6 +175,25 @@ func (o *frontmatterSetOperation) Execute(
return nil
}

// appendDisplacedSession appends the outgoing owner to metrics_sessions when a
// claude_session_id write replaces a different non-empty id. The exit and close
// paths resolve a session by matching claude_session_id or any metrics_sessions
// entry, so the displaced id must stay on the task or it can never be resolved
// again. A first bind (no previous owner) and a re-bind to the same value append
// nothing. The incoming id is deliberately not appended: work-on and the
// session-connect append are the writers that establish ownership, and appending
// both would double-count the incoming session.
func (o *frontmatterSetOperation) appendDisplacedSession(task *domain.Task, key, value string) {
previous := task.ClaudeSessionID()
if key != "claude_session_id" || previous == "" || previous == value {
return
}
task.AppendMetricsSession(domain.MetricsSession{
SessionID: previous,
StartedAt: libtime.DateOrDateTime(o.currentDateTime.Now().Time()),
})
}

// writeTaskCloseOutFieldsIfCloseOut writes the one-step close-out fields when
// the target status is a close-out (aborted or completed), returning the first
// write error. Fields are written only when provided; non-close-out targets
Expand Down
Loading
Loading